Files
Cyrene_v2/internal/services/gateway/history_toolcalls_test.go
T
AskaEth c907a34510 feat(gateway): 面 1 补齐 —— 消息读取面 toolCalls[](工具回看)
前提由 FV2 提供(图自 v0.77 起带每路 turnId、每路各落一行)。本片:
- 迁移 0023:flow_graphs 加「按路查询键」(写入时回填图的 TurnID),老行按缺席处理
- GET /api/v1/sessions/{id}/messages 加**可选** toolCalls[],形状严格照 Docs/api-client.md
  v0.76 ① 已登记的:{name, status(ok|error|unknown), durationMs, argsSummary, resultSummary}
- 🔴 图取不到 ⇒ **字段整体缺席**(绝不回空数组冒充「没调过工具」)
- 一条批量 SQL(按页内 turnId 集合),禁 N+1;单条 ≤20 个工具、resultSummary ≤500 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
2026-10-06 09:03:55 +08:00

530 lines
21 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 gateway
// 本文件是**工具回看面**(契约 v0.76 ① 的 `toolCalls[]`;切片 CODE-TC)的验收。
//
// 它走**真路径**:真内存 SQLite(history 库 + flow_graphs 库各一)、真 `flowgraph.SQLStore`
// 落盘、真 HTTP handler 读回,并用**真建图器**(`trace.RunGraph` + `AddSpan`)造图 ——
// 因此节点的形状与脱敏都是生产同一条路径的产物,而不是测试手搓的形状。
//
// 钉住的四件事(缺一件这个特性就是错的):
//
// - **形状**:五个键(name/status/durationMs/argsSummary/resultSummary),三态闭集
// (ok|error|unknown),**没有第六个键**;
// - **缺席语义**:图取不到 ⇒ 字段**整体缺席**,绝不回 `[]`(那是「没调过工具」的谎);
// - **一次批量 SQL**:整页只调一次 `GraphsByTurnID`(禁 N+1);
// - **只搬运、不放宽**:摘要逐字来自图里已脱敏的 props,读侧不新增、不改写。
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// toolCallEnv 是本文件的夹具:history 与 flow_graphs 各一个隔离内存库,两者都经
// 真实存储实现注入网关。
type toolCallEnv struct {
ts *httptest.Server
jwt *auth.JWTService
history *history.Store
store *flowgraph.SQLStore
}
// newToolCallEnv 打开两个内存库(history / flow_graphs)、注入网关并把 store 交回
// 给用例写图。log 为 nil 时用丢弃 logger;wireStore 为 false 时**不接线**图存储
// (演练「图源未接线」这一支)。
func newToolCallEnv(t *testing.T, deps Deps, log *slog.Logger, wireStore bool) *toolCallEnv {
t.Helper()
ctx := context.Background()
if log == nil {
log = testLogger()
}
histConn, err := db.Open(ctx, ":memory:", log)
if err != nil {
t.Fatalf("open history db: %v", err)
}
t.Cleanup(func() { _ = histConn.Close() })
st, err := history.NewStore(ctx, histConn, log)
if err != nil {
t.Fatalf("new history store: %v", err)
}
flowConn, err := db.Open(ctx, ":memory:", log)
if err != nil {
t.Fatalf("open flow db: %v", err)
}
t.Cleanup(func() { _ = flowConn.Close() })
if err := db.MigrateUp(ctx, flowConn, log); err != nil {
t.Fatalf("migrate flow db: %v", err)
}
store, err := flowgraph.NewStore(flowgraph.Config{DB: flowConn, Log: log})
if err != nil {
t.Fatalf("new flowgraph store: %v", err)
}
if deps.History == nil {
deps.History = st
}
if deps.FlowGraphs == nil && wireStore {
deps.FlowGraphs = store
}
deps.Log = log
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
return &toolCallEnv{ts: ts, jwt: jwt, history: st, store: store}
}
// pageMessages 取一页消息并按内容映射成「线上 JSON 对象」,便于逐条断言「键在不在」
// 与「键的形状」—— 只有原始 JSON 才看得见「缺席」与「空数组」的区别。
func (e *toolCallEnv) pageMessages(t *testing.T, tok string) map[string]map[string]json.RawMessage {
t.Helper()
resp, body := doReq(t, http.MethodGet, e.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list messages status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var raw []map[string]json.RawMessage
decodeDataSlice(t, body, &raw)
out := make(map[string]map[string]json.RawMessage, len(raw))
for _, m := range raw {
var content string
if err := json.Unmarshal(m["content"], &content); err != nil {
t.Fatalf("decode content: %v", err)
}
out[content] = m
}
return out
}
// seedTurnMessages 落两条同一路的消息(user + assistant)与一条没有路的旧行,
// 返回它们的 createdAt(断言顺序用不上,但保留可读的坐标)。
func seedTurnMessages(t *testing.T, st *history.Store, lane string) {
t.Helper()
ctx := context.Background()
for _, m := range []history.Message{
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问:" + lane, TurnID: lane, CreatedAt: 1000},
{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "答:" + lane, TurnID: lane, CreatedAt: 1500},
{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "旧行(没有路)", CreatedAt: 2000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
}
// toolSpan 造一条**已完成**的工具 span 记录:props 就是生产写入侧(loop/spanlog.go 的
// recordToolDone)会落在 span 上的那几个键,因此图构建器会按真脱敏路径处理它。
func toolSpan(id, name string, status trace.SpanStatus, started, ended time.Time, props map[string]any) trace.SpanRecord {
return trace.SpanRecord{
ID: id,
ParentID: "input",
Name: "tool." + name,
Kind: trace.SpanKindTool,
Status: status,
StartedAt: started,
EndedAt: ended,
Props: props,
}
}
// laneGraphWithTools 用**真建图器**造一条路的收尾图(输入 → 工具 ×N → 输出)。
func laneGraphWithTools(session, lane string, seq int, tools []trace.SpanRecord) *trace.FlowGraph {
start := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
g := trace.NewRunGraph(session)
g.SetTurnID(lane)
g.SetSeq(seq)
g.Start(seq, start)
g.SeedInput("问题")
for _, rec := range tools {
g.AddSpan(rec)
}
g.SeedOutput("答复")
return g.Snapshot()
}
// toolCallKeys 是契约 v0.76 ① 允许出现的**全部**键(形状是闭集:多一个键就是扩了契约)。
var toolCallKeys = map[string]bool{
"name": true, "status": true, "durationMs": true, "argsSummary": true, "resultSummary": true,
}
// assertOnlyContractKeys 钉住「五个键之内」:任何多余键都说明契约被悄悄扩了。
func assertOnlyContractKeys(t *testing.T, where string, entry map[string]json.RawMessage) {
t.Helper()
for k := range entry {
if !toolCallKeys[k] {
t.Fatalf("%s: 出现了契约外的键 %q(形状:%v)", where, k, entry)
}
}
}
// TestMessagesToolCallsShaped_正常 是本文件的核心:一轮跑过两个工具(成功 + 失败)的
// 回复,读接口把五个键如实带出来,且**同一路的两条消息**都带(提问那条也带 —— 客户端
// 因此能在提问气泡上显示「这一问触发了什么」)。
func TestMessagesToolCallsShaped_正常(t *testing.T) {
t.Parallel()
env := newToolCallEnv(t, Deps{}, nil, true)
seedTurnMessages(t, env.history, "turn-a")
start := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
g := laneGraphWithTools(history.MainSessionID, "turn-a", 1, []trace.SpanRecord{
toolSpan("span-ok", "web_search", trace.SpanStatusOK, start, start.Add(1200*time.Millisecond),
map[string]any{
"tool": "web_search",
"args_summary": map[string]any{"query": "明天天气"},
// `result`(工具返回的原始结果)才是写入侧真实落的键:图构建器在出图时
// 把它洗成 `result_summary`(RedactText + 80 rune 截断),这里刻意给原始
// 形状,走的就是生产那条路。
"result": "晴,18~26℃",
}),
toolSpan("span-err", "read_file", trace.SpanStatusError, start, start.Add(30*time.Millisecond),
map[string]any{
"tool": "read_file",
"error_kind": "not_found",
"error": "文件不存在:/tmp/x.txt",
}),
})
// 上面这些键都在 trace 的 props 放行表内(tool / args_summary / result / error /
// error_kind),因此图里真实存下来的内容就是它们(`result` 会在出图时被洗成
// `result_summary`),而不是测试硬塞的形状。
if err := env.store.RecordRound(g); err != nil {
t.Fatalf("落盘轮次图: %v", err)
}
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
msgs := env.pageMessages(t, tok)
for _, content := range []string{"问:turn-a", "答:turn-a"} {
raw, ok := msgs[content]
if !ok {
t.Fatalf("响应里找不到 %q(body 里的内容 = %v)", content, keysOfRaw(msgs))
}
calls := decodeToolCalls(t, content, raw)
if len(calls) != 2 {
t.Fatalf("%s 的 toolCalls 条数 = %d,want 2(%v)", content, len(calls), calls)
}
if calls[0]["name"] != "web_search" || calls[0]["status"] != "ok" {
t.Fatalf("%s 第 1 条 = %v,want web_search/ok", content, calls[0])
}
if calls[0]["durationMs"] != float64(1200) {
t.Fatalf("%s 第 1 条 durationMs = %v,want 1200", content, calls[0]["durationMs"])
}
if calls[0]["argsSummary"] != `{"query":"明天天气"}` {
t.Fatalf("%s 第 1 条 argsSummary = %v,want 紧凑 JSON(图上那份已脱敏内容)", content, calls[0]["argsSummary"])
}
if calls[0]["resultSummary"] != "晴,18~26℃" {
t.Fatalf("%s 第 1 条 resultSummary = %v", content, calls[0]["resultSummary"])
}
if calls[1]["name"] != "read_file" || calls[1]["status"] != "error" {
t.Fatalf("%s 第 2 条 = %v,want read_file/error", content, calls[1])
}
// 失败的调用在图上只有 error 摘要:读侧如实回它,否则 status:"error" 只剩
// 一个「失败了」而没有任何原因。
if calls[1]["resultSummary"] != "文件不存在:/tmp/x.txt" {
t.Fatalf("%s 第 2 条 resultSummary = %v,want 图上的 error 摘要", content, calls[1]["resultSummary"])
}
}
// 没有路标识的旧行:**键不存在**(不是空数组)。
if legacy, ok := msgs["旧行(没有路)"]; ok {
if _, present := legacy["toolCalls"]; present {
t.Fatalf("无 turnId 的行不该有 toolCalls:%s", legacy["toolCalls"])
}
} else {
t.Fatal("响应里找不到旧行")
}
// 其余出口共用同一个结构体,但**绝不**被填充:把 DTO 直接编码也没有该键
// (本特性只挂在读接口上)。
b, err := json.Marshal(toHistoryMessage(history.Message{ID: "m", SessionID: "web", Content: "x", TurnID: "turn-a"}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), "toolCalls") {
t.Fatalf("toHistoryMessage 不该产出 toolCalls(其余出口必须逐字不变):%s", b)
}
}
// TestMessagesToolCallsAbsentWhenNoGraph 钉住缺席语义:**图取不到 ⇒ 字段整体缺席**,
// 绝不回 `[]` 去冒充「这一轮没调过工具」。
func TestMessagesToolCallsAbsentWhenNoGraph(t *testing.T) {
t.Parallel()
env := newToolCallEnv(t, Deps{}, nil, true)
seedTurnMessages(t, env.history, "turn-a")
// 落一条**没有工具节点**的图:这一路确实没调过工具 ⇒ 仍然缺席(客户端只能做
// 正向断言:有键 ⇒ 调过工具)。
noTools := laneGraphWithTools(history.MainSessionID, "turn-a", 1, nil)
if err := env.store.RecordRound(noTools); err != nil {
t.Fatalf("落盘无工具的图: %v", err)
}
// 另落一条别的路的图,确认不会被错配到这条路。
other := laneGraphWithTools(history.MainSessionID, "turn-z", 2, []trace.SpanRecord{
toolSpan("span-z", "some_tool", trace.SpanStatusOK, time.Now(), time.Now().Add(time.Millisecond), map[string]any{"tool": "some_tool"}),
})
if err := env.store.RecordRound(other); err != nil {
t.Fatalf("落盘别的路的图: %v", err)
}
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
msgs := env.pageMessages(t, tok)
for _, content := range []string{"问:turn-a", "答:turn-a", "旧行(没有路)"} {
raw, ok := msgs[content]
if !ok {
t.Fatalf("响应里找不到 %q", content)
}
if v, present := raw["toolCalls"]; present {
t.Fatalf("%s 不该有 toolCalls(没有可用作回看依据的图),得到 %s", content, v)
}
}
}
// TestMessagesToolCallsOneBatchQuery 钉住**禁 N+1**:一页消息无论多少条、多少个路标识,
// 只允许调一次批量查图,且集合就是页内非空的那些标识。
func TestMessagesToolCallsOneBatchQuery(t *testing.T) {
t.Parallel()
stub := &stubFlowGraphStore{}
env := newToolCallEnv(t, Deps{FlowGraphs: stub}, nil, true)
ctx := context.Background()
for _, m := range []history.Message{
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问1", TurnID: "turn-a", CreatedAt: 1000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问2", TurnID: "turn-b", CreatedAt: 1100},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问3", TurnID: "turn-a", CreatedAt: 1200},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问4", CreatedAt: 1300},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问5", TurnID: "turn-c", CreatedAt: 1400},
} {
if err := env.history.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
_ = env.pageMessages(t, tok)
if len(stub.calls) != 1 {
t.Fatalf("查图调用次数 = %d,want 1(一页一次批量,禁 N+1)", len(stub.calls))
}
if got := len(stub.calls[0]); got != 3 {
t.Fatalf("批量集合大小 = %d,want 3(页内去重前的非空路标识:turn-a ×2 + turn-b + turn-c 去重后 3): %v",
got, stub.calls[0])
}
seen := map[string]int{}
for _, id := range stub.calls[0] {
seen[id]++
}
for _, want := range []string{"turn-a", "turn-b", "turn-c"} {
if seen[want] == 0 {
t.Fatalf("批量集合里缺 %q:%v", want, stub.calls[0])
}
}
if seen[""] > 0 {
t.Fatalf("空标识不该进查询:%v", stub.calls[0])
}
}
// TestMessagesToolCallsQueryFailureDegrades 钉住失败处置:查图失败**不让正文陪葬**
// (仍 200、内容照旧),只让回看面缺席,并留下一条带来源的 WARN —— 绝不静默。
func TestMessagesToolCallsQueryFailureDegrades(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
log := slog.New(slog.NewTextHandler(&logs, nil))
stub := &stubFlowGraphStore{err: errors.New("boom")}
env := newToolCallEnv(t, Deps{FlowGraphs: stub}, log, true)
seedTurnMessages(t, env.history, "turn-a")
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
msgs := env.pageMessages(t, tok)
raw := msgs["答:turn-a"]
if raw == nil {
t.Fatal("查图失败不该影响正文")
}
if _, present := raw["toolCalls"]; present {
t.Fatalf("查图失败时必须缺席,得到 %s", raw["toolCalls"])
}
if !strings.Contains(logs.String(), "history tool calls unavailable") {
t.Fatalf("查图失败必须留下可见的 WARN,日志 = %q", logs.String())
}
}
// TestMessagesToolCallsCapsAndStatus_边界 钉住三个上界与三态映射:
// - 单条消息最多 20 条工具(多出来的按图序截断);
// - argsSummary / resultSummary 各自 ≤ 500 rune(图上写入侧只截到 240 —— 这里的
// fixture 刻意**手搓**一条更长的摘要,模拟「写入侧放宽/第三方写入者」);
// - 状态闭集:done⇒ok、error⇒error、其余(running/idle/空)⇒unknown。
func TestMessagesToolCallsCapsAndStatus_边界(t *testing.T) {
t.Parallel()
env := newToolCallEnv(t, Deps{}, nil, true)
ctx := context.Background()
if err := env.history.Append(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "回复", TurnID: "turn-cap", CreatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
longResult := strings.Repeat("结", 600)
longArgs := map[string]any{"query": strings.Repeat("查", 80), "path": strings.Repeat("p", 80),
"name": strings.Repeat("n", 80), "url": strings.Repeat("u", 80), "id": strings.Repeat("i", 80),
"kind": strings.Repeat("k", 80)}
nodes := make([]trace.FlowNode, 0, 35)
for i := 0; i < 25; i++ {
status := trace.FlowStatusDone
switch {
case i == 1:
status = trace.FlowStatusError
case i == 2:
status = trace.FlowStatusRunning
case i == 3:
status = trace.FlowStatusIdle
case i == 4:
status = ""
}
nodes = append(nodes, trace.FlowNode{
ID: "tool-" + itoa(i), Label: "tool-" + itoa(i), Kind: "tool", Status: status,
StartedAt: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC),
EndedAt: time.Date(2026, 10, 6, 12, 0, 1, 0, time.UTC),
Props: map[string]any{
"tool": "tool-" + itoa(i),
"args_summary": longArgs,
"result_summary": longResult,
},
})
}
// 非工具节点绝不能被当工具回看。
nodes = append(nodes,
trace.FlowNode{ID: "llm-1", Label: "思考", Kind: "llm", Status: trace.FlowStatusDone},
trace.FlowNode{ID: "output", Label: "答复", Kind: "output", Status: trace.FlowStatusDone})
if err := env.store.RecordRound(&trace.FlowGraph{
ID: "web-1#turn-cap", SessionID: history.MainSessionID, TurnID: "turn-cap", Nodes: nodes,
}); err != nil {
t.Fatalf("落盘: %v", err)
}
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
raw := env.pageMessages(t, tok)["回复"]
calls := decodeToolCalls(t, "回复", raw)
if len(calls) != maxToolCallsPerMessage {
t.Fatalf("toolCalls 条数 = %d,want %d(单条消息的工具上限)", len(calls), maxToolCallsPerMessage)
}
if calls[0]["name"] != "tool-0" || calls[len(calls)-1]["name"] != "tool-"+itoa(maxToolCallsPerMessage-1) {
t.Fatalf("截断必须按图序取前 %d 条:首=%v 尾=%v", maxToolCallsPerMessage, calls[0]["name"], calls[len(calls)-1]["name"])
}
wantStatus := map[int]string{0: "ok", 1: "error", 2: "unknown", 3: "unknown", 4: "unknown"}
for i, want := range wantStatus {
if calls[i]["status"] != want {
t.Fatalf("tool-%d status = %v,want %v(三态闭集)", i, calls[i]["status"], want)
}
}
for i, call := range calls {
if call["status"] != "ok" && call["status"] != "error" && call["status"] != "unknown" {
t.Fatalf("tool-%d status = %v 不在闭集内", i, call["status"])
}
if args, _ := call["argsSummary"].(string); len([]rune(args)) > maxToolCallSummaryRunes {
t.Fatalf("tool-%d argsSummary 长度 = %d rune,超过上限 %d", i, len([]rune(args)), maxToolCallSummaryRunes)
}
if res, _ := call["resultSummary"].(string); len([]rune(res)) > maxToolCallSummaryRunes {
t.Fatalf("tool-%d resultSummary 长度 = %d rune,超过上限 %d", i, len([]rune(res)), maxToolCallSummaryRunes)
}
}
// 截断必须看得见(末位是省略号),否则客户端会以为这就是完整的摘要。
if res := calls[0]["resultSummary"].(string); !strings.HasSuffix(res, "…") {
t.Fatalf("被截断的 resultSummary 必须以省略号结尾(长度 %d rune)", len([]rune(res)))
}
}
// TestMessagesToolCallsNilStore 钉住「图源未接线」:字段缺席、端点照常 200、正文
// 一字不差 —— 未接线不是错误,只是没有这一面(与 `flow_unavailable` 的管理端点不同:
// 消息读取面绝不因回看面缺失而 5xx)。
func TestMessagesToolCallsNilStore(t *testing.T) {
t.Parallel()
env := newToolCallEnv(t, Deps{}, nil, false)
seedTurnMessages(t, env.history, "turn-a")
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200(回看面缺失不该让正文 5xx)", resp.StatusCode)
}
if strings.Contains(string(body), "toolCalls") {
t.Fatalf("图源未接线时不该出现 toolCalls:%s", body)
}
}
// stubFlowGraphStore 是只读查询桩:记录每次批量查询的标识集合(钉住「一页一次」),
// 并可按需注入失败。
type stubFlowGraphStore struct {
calls [][]string
err error
}
func (s *stubFlowGraphStore) Graph(context.Context, string, string) (*trace.FlowGraph, error) {
return nil, nil
}
func (s *stubFlowGraphStore) Latest(context.Context, string) (*trace.FlowGraph, error) {
return nil, nil
}
func (s *stubFlowGraphStore) Turns(context.Context, string, int) ([]flowgraph.Turn, error) {
return nil, nil
}
func (s *stubFlowGraphStore) GraphsByTurnID(_ context.Context, _ string, ids []string) (map[string]*trace.FlowGraph, error) {
s.calls = append(s.calls, append([]string(nil), ids...))
if s.err != nil {
return nil, s.err
}
return map[string]*trace.FlowGraph{}, nil
}
// decodeToolCalls 解码某条消息的 toolCalls,并顺手钉住形状闭集。
func decodeToolCalls(t *testing.T, where string, raw map[string]json.RawMessage) []map[string]any {
t.Helper()
payload, present := raw["toolCalls"]
if !present {
t.Fatalf("%s 缺 toolCalls(want 有)", where)
}
var entries []map[string]json.RawMessage
if err := json.Unmarshal(payload, &entries); err != nil {
t.Fatalf("%s 的 toolCalls 不是数组: %v (%s)", where, err, payload)
}
out := make([]map[string]any, 0, len(entries))
for i, entry := range entries {
assertOnlyContractKeys(t, where+"["+itoa(i)+"]", entry)
var m map[string]any
b, err := json.Marshal(entry)
if err != nil {
t.Fatalf("re-marshal entry: %v", err)
}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("decode entry: %v", err)
}
for _, must := range []string{"name", "status"} {
if _, ok := entry[must]; !ok {
t.Fatalf("%s[%d] 缺必在键 %q:%v", where, i, must, entry)
}
}
out = append(out, m)
}
return out
}
// keysOfRaw 列出响应里出现过哪些内容(失败信息用)。
func keysOfRaw(m map[string]map[string]json.RawMessage) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}