package main import ( "context" "encoding/json" "fmt" "sort" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // surfaceApp 组装一个只含能力面所需的 app:注册表 + 分发表 + mock IoT 子系统。 // 它走的是与生产相同的两个入口(wireTranscode / wireCapabilitySurface), // 因此断言的是真实接线,而不是测试自造的替身。 func surfaceApp(t *testing.T) *app { t.Helper() caps := registry.New() d, err := tool.New(caps) if err != nil { t.Fatalf("tool.New: %v", err) } mgr, err := iot.New(config.IoTConfig{Provider: "mock"}, testLog()) if err != nil { t.Fatalf("iot.New: %v", err) } a := &app{log: testLog(), caps: caps, dispatcher: d, iot: mgr} a.cfg = &config.Config{} a.cfg.Capacity.Enabled = true a.wireTranscode() a.wireCapabilitySurface() return a } // toolNames 返回注册表里当前所有**可调用工具**的名字(模型真正会看到的那一面)。 func toolNames(a *app) []string { caps := a.caps.List(registry.KindTool) out := make([]string, 0, len(caps)) for _, c := range caps { out = append(out, c.Name) } return out } // TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool 断言 (a)+(c) 的组合根 // 落地:IoT 子系统以 KindIoT 进能力面(此前全仓无人注册该 kind)、只读的 // capability_list 进注册表并被广告给模型,且它读出的外围状态经真实分发器可用。 func TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool(t *testing.T) { t.Parallel() a := surfaceApp(t) iotCap, err := a.caps.Get(iotCapabilityName) if err != nil { t.Fatalf("IoT 子系统没进能力注册表: %v", err) } if iotCap.Kind != registry.KindIoT { t.Fatalf("IoT 能力类型 = %q, want %q", iotCap.Kind, registry.KindIoT) } if _, ok := iotCap.Value.(*iot.Manager); !ok { t.Fatalf("IoT 能力值类型 = %T, want *iot.Manager", iotCap.Value) } listCap, err := a.caps.Get(registry.ToolName) if err != nil { t.Fatalf("capability_list 没进能力注册表: %v", err) } if listCap.Kind != registry.KindTool { t.Fatalf("清单能力类型 = %q, want %q", listCap.Kind, registry.KindTool) } if listCap.Policy.AllowInGroup || listCap.Policy.AllowInStranger { t.Fatalf("清单工具的策略必须保持 fail-closed(owner-only),got %+v", listCap.Policy) } advertised := false for _, s := range a.dispatcher.ToolSchemas() { if s.Name == registry.ToolName { advertised = true } } if !advertised { t.Fatalf("capability_list 未被广告给模型:%v", toolNames(a)) } // 走真实分发器(含 JSON-Schema 校验与工具执行),断言外围只读状态真的读得出来。 devices, err := a.iot.List(context.Background()) if err != nil { t.Fatalf("iot.List: %v", err) } val, err := a.dispatcher.Invoke(context.Background(), registry.ToolName, map[string]any{}) if err != nil { t.Fatalf("Invoke(%s): %v", registry.ToolName, err) } raw, err := json.Marshal(val) if err != nil { t.Fatalf("marshal: %v", err) } text := string(raw) if !strings.Contains(text, fmt.Sprintf(`"devices=%d`, len(devices))) { t.Fatalf("清单没报出外围设备台数(want devices=%d):%s", len(devices), text) } if !strings.Contains(text, `"name":"iot"`) || !strings.Contains(text, `"kind":"iot"`) { t.Fatalf("清单没列出 IoT 子系统条目:%s", text) } if !strings.Contains(text, `"name":"transcode"`) { t.Fatalf("清单没列出已注册的 transcode 工具:%s", text) } // 只读:清单调用(含外围状态读取)不得改动任何设备。 after, err := a.iot.List(context.Background()) if err != nil { t.Fatalf("iot.List (after): %v", err) } if got, want := deviceSnapshot(after), deviceSnapshot(devices); got != want { t.Fatalf("清单调用改动了设备状态(只读红线):\nwant=%s\ngot=%s", want, got) } } // deviceSnapshot 把设备清单渲染成可比较的快照(按 id 排序,状态键排序)。 func deviceSnapshot(devices []iot.Device) string { lines := make([]string, 0, len(devices)) for _, d := range devices { keys := make([]string, 0, len(d.State)) for k := range d.State { keys = append(keys, k) } sort.Strings(keys) pairs := make([]string, 0, len(keys)) for _, k := range keys { pairs = append(pairs, fmt.Sprintf("%s=%v", k, d.State[k])) } lines = append(lines, fmt.Sprintf("%s|%s|%s|%t|%s", d.ID, d.Name, d.Type, d.Online, strings.Join(pairs, ","))) } sort.Strings(lines) return strings.Join(lines, "\n") } // TestWireCapabilitySurface_NewToolsReachableUnderCapacity 断言 (b):CAPACITY_ENABLED=true // 时**新工具**(transcode / capability_list)在静息与唤醒两个内置档位集的允许集内。 // // 判据用**注册表里的真实工具名**驱动判定器:若 signals.go 的档位集字面量与注册名 // 漂移(例如把 capability_list 写成 capability-list),该名字就不在 AllToolNames 里、 // 交集后消失 ⇒ 本用例变红。 func TestWireCapabilitySurface_NewToolsReachableUnderCapacity(t *testing.T) { t.Parallel() a := surfaceApp(t) dec := a.buildCapacity() if dec == nil || !dec.Enabled() { t.Fatal("CAPACITY_ENABLED=true 时判定器必须启用") } names := toolNames(a) cases := []struct { name string msg string want capacity.State }{ {"静息", "你好", capacity.StateQuiet}, {"唤醒", "帮我查一下资料", capacity.StateAwake}, } for _, c := range cases { dec := dec.Decide(context.Background(), capacity.Input{ Messages: []string{c.msg}, CurrentState: capacity.StateQuiet, AllToolNames: names, }) if dec.State != c.want { t.Fatalf("输入 %q 判定态 = %s, want %s", c.msg, dec.State, c.want) } for _, want := range []string{"transcode", registry.ToolName} { if !containsTool(dec.AllowedTools, want) { t.Fatalf("%s档位允许集缺 %q:%v(新工具被挡住 = 裁决 15 在安静态失效)", c.name, want, dec.AllowedTools) } } } } // containsTool 报告名字是否在允许集里。 func containsTool(names []string, want string) bool { for _, n := range names { if n == want { return true } } return false }