Files
HomeAgent/internal/plugins/remotedevice/binary_test.go
JianFeeeee 5836c2ce5c refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
2026-09-11 11:45:24 +08:00

865 lines
26 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 remotedevice
import (
"bufio"
"encoding/base64"
"encoding/binary"
"encoding/json"
"math/rand"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// ===== 测试用最小 WS 客户端(模拟 GUI 设备桥)=====
type testWSClient struct {
conn net.Conn
rw *bufio.ReadWriter
}
func dialTestWS(t *testing.T, url, token string) *testWSClient {
t.Helper()
req := "GET /api/v1/device/ws?token=" + token + " HTTP/1.1\r\n" +
"Host: test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n"
conn, err := net.Dial("tcp", strings.TrimPrefix(url, "http://"))
if err != nil {
t.Fatalf("dial: %v", err)
}
if _, err := conn.Write([]byte(req)); err != nil {
t.Fatalf("write upgrade: %v", err)
}
br := bufio.NewReader(conn)
resp, err := readLine(br)
if err != nil {
t.Fatalf("read upgrade resp: %v", err)
}
if !strings.Contains(resp, "101") {
t.Fatalf("expected 101, got %s", resp)
}
for {
line, err := readLine(br)
if err != nil {
t.Fatalf("read headers: %v", err)
}
if line == "\r\n" || line == "" {
break
}
}
return &testWSClient{conn: conn, rw: &bufio.ReadWriter{Reader: br, Writer: bufio.NewWriter(conn)}}
}
func readLine(br *bufio.Reader) (string, error) {
var sb strings.Builder
for {
b, err := br.ReadByte()
if err != nil {
return sb.String(), err
}
sb.WriteByte(b)
if b == '\n' {
return sb.String(), nil
}
}
}
// sendText 发送客户端文本帧带掩码RFC6455 要求客户端帧必须掩码)
func (c *testWSClient) sendText(payload []byte) {
c.sendFrame(0x1, payload)
}
func (c *testWSClient) sendBinary(payload []byte) {
c.sendFrame(0x2, payload)
}
func (c *testWSClient) sendFrame(opcode byte, payload []byte) {
maskKey := make([]byte, 4)
rand.Read(maskKey)
masked := make([]byte, len(payload))
for i := range payload {
masked[i] = payload[i] ^ maskKey[i%4]
}
var hdr []byte
hdr = append(hdr, 0x80|opcode)
n := len(payload)
switch {
case n < 126:
hdr = append(hdr, 0x80|byte(n))
case n <= 0xffff:
hdr = append(hdr, 0x80|126)
ext := make([]byte, 2)
binary.BigEndian.PutUint16(ext, uint16(n))
hdr = append(hdr, ext...)
default:
hdr = append(hdr, 0x80|127)
ext := make([]byte, 8)
binary.BigEndian.PutUint64(ext, uint64(n))
hdr = append(hdr, ext...)
}
c.rw.Write(hdr)
c.rw.Write(maskKey)
c.rw.Write(masked)
c.rw.Flush()
}
// readMsg 读一帧(跳过 pong返回 opcode 与 payload
func (c *testWSClient) readMsg() (byte, []byte, error) {
for {
payload, isClose, opcode, err := readFrame(c.rw.Reader)
if err != nil || isClose {
return 0, nil, err
}
if opcode == 0xa {
continue
}
return opcode, payload, nil
}
}
func (c *testWSClient) close() { c.conn.Close() }
func (c *testWSClient) bindDevice(t *testing.T, deviceID, token string) {
t.Helper()
c.sendText(mustJSON(map[string]interface{}{
"op": "bind", "device_id": deviceID, "token": token,
}))
op, payload, err := c.readMsg()
if err != nil {
t.Fatalf("read bind_ack: %v", err)
}
if op != 0x1 {
t.Fatalf("expected bind_ack text frame, got %x", op)
}
var ack map[string]interface{}
if err := json.Unmarshal(payload, &ack); err != nil {
t.Fatalf("decode bind_ack: %v", err)
}
if ack["op"] != "bind_ack" || ack["ok"] != true {
t.Fatalf("bind rejected: %v", ack)
}
}
func (c *testWSClient) readHelloAck(t *testing.T) string {
t.Helper()
op, payload, err := c.readMsg()
if err != nil {
t.Fatalf("read hello_ack: %v", err)
}
if op != 0x1 {
t.Fatalf("expected hello_ack text frame, got %x", op)
}
var ack map[string]interface{}
if err := json.Unmarshal(payload, &ack); err != nil {
t.Fatalf("decode hello_ack: %v", err)
}
deviceID, _ := ack["device"].(string)
if ack["op"] != "hello_ack" || deviceID == "" {
t.Fatalf("expected hello_ack with device id, got %v", ack)
}
return deviceID
}
func (c *testWSClient) readHelloAckAndBind(t *testing.T, token string) {
t.Helper()
deviceID := c.readHelloAck(t)
c.bindDevice(t, deviceID, token)
}
// ===== 端到端hello/bind/cmd + 二进制分块回传(录像协议)=====
func TestWSBinaryChunkUpload(t *testing.T) {
reg := NewRegistry()
token := "test-token-123"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
url := srv.URL
cli := dialTestWS(t, url, token)
defer cli.close()
// hello 后必须完成 bind设备才会注册并开始处理数据。
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-test","name":"测试机","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
// 模拟设备收到 cmd 后以二进制分块回传cmd_data_start → 0x2×N → cmd_data_end
videoData := make([]byte, 20000) // 跨多个 8KB 块
for i := range videoData {
videoData[i] = byte(i % 251)
}
go func() {
time.Sleep(100 * time.Millisecond)
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_data_start", "req_id": "req-video-1",
"kind": "camera_video", "mime": "video/mp4",
"total": len(videoData), "chunk_size": 8192,
}))
const chunk = 8192
for off := 0; off < len(videoData); off += chunk {
end := off + chunk
if end > len(videoData) {
end = len(videoData)
}
cli.sendBinary(videoData[off:end])
}
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_data_end", "req_id": "req-video-1", "status": "ok", "total": len(videoData),
}))
}()
// 服务端等待聚合结果AwaitResult 由 deliverResult 唤醒)
res, err := reg.AwaitResult("req-video-1", 5*time.Second)
if err != nil {
t.Fatalf("await aggregated result: %v", err)
}
if res["status"] != "ok" {
t.Fatalf("expected status ok, got %v", res["status"])
}
if got, _ := res["size"].(int); got != len(videoData) {
t.Fatalf("size mismatch: got %v want %d", res["size"], len(videoData))
}
// 校验 base64 数据完整性
stored, ok := reg.GetResult("req-video-1")
if !ok {
t.Fatal("result not persisted")
}
b64, _ := stored["data_base64"].(string)
if len(b64) == 0 {
t.Fatal("data_base64 empty")
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatalf("decode base64: %v", err)
}
if string(decoded) != string(videoData) {
t.Fatal("decoded data mismatch")
}
}
// ===== 媒体落盘SetMediaDir 后 cmd_result 返回 file 路径而非 base64 内联 =====
func TestWSBinaryMediaToFile(t *testing.T) {
reg := NewRegistry()
token := "test-token-123"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
mediaDir := t.TempDir()
reg.SetMediaDir(mediaDir)
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-media","name":"媒体机","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
videoData := make([]byte, 30000)
for i := range videoData {
videoData[i] = byte(i % 253)
}
go func() {
time.Sleep(50 * time.Millisecond)
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_data_start", "req_id": "req-file-1",
"kind": "camera_video", "mime": "video/mp4",
"total": len(videoData),
}))
const chunk = 8192
for off := 0; off < len(videoData); off += chunk {
end := off + chunk
if end > len(videoData) {
end = len(videoData)
}
cli.sendBinary(videoData[off:end])
}
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_data_end", "req_id": "req-file-1", "status": "ok",
}))
}()
res, err := reg.AwaitResult("req-file-1", 5*time.Second)
if err != nil {
t.Fatalf("await result: %v", err)
}
// 落盘模式file 字段存在且内容一致;不应再有 data_base64
fp, ok := res["file"].(string)
if !ok || fp == "" {
t.Fatalf("expected file path in result, got %v", res)
}
if _, hasB64 := res["data_base64"]; hasB64 {
t.Fatal("data_base64 should be absent in file mode")
}
if want := filepath.Join(mediaDir, "req-file-1.mp4"); fp != want {
t.Fatalf("file path = %s, want %s", fp, want)
}
got, err := os.ReadFile(fp)
if err != nil {
t.Fatalf("read media file: %v", err)
}
if string(got) != string(videoData) {
t.Fatal("media file content mismatch")
}
}
// ===== 端到端PushData 下发音频(网关→设备 cmd_speech 协议)=====
func TestWSPushDataAudio(t *testing.T) {
reg := NewRegistry()
token := "test-token-456"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"audio-dev","name":"音频机","kind":"speaker"}}`))
cli.readHelloAckAndBind(t, token)
audioData := []byte("RIFF....fake-wav-audio-data-for-testing....")
// 异步下发音频
errCh := make(chan error, 1)
go func() {
errCh <- reg.PushData("audio-dev", "req-speech-1", "speech", "audio/wav", audioData)
}()
// 设备侧按协议读取start 文本帧 → N 个二进制帧 → end 文本帧
var start map[string]interface{}
var chunks [][]byte
var end map[string]interface{}
deadline := time.After(5 * time.Second)
for end == nil {
select {
case <-deadline:
t.Fatal("timeout reading speech protocol frames")
default:
}
op, payload, err := cli.readMsg()
if err != nil {
t.Fatalf("read frame: %v", err)
}
switch op {
case 0x1:
var msg map[string]interface{}
json.Unmarshal(payload, &msg)
switch msg["op"] {
case "cmd_speech_start":
start = msg
case "cmd_speech_end":
end = msg
}
case 0x2:
chunks = append(chunks, payload)
}
}
if err := <-errCh; err != nil {
t.Fatalf("PushData error: %v", err)
}
if start == nil || start["op"] != "cmd_speech_start" {
t.Fatal("missing cmd_speech_start")
}
if start["mime"] != "audio/wav" || start["kind"] != "speech" {
t.Fatalf("unexpected start fields: %v", start)
}
if int(start["total"].(float64)) != len(audioData) {
t.Fatalf("total mismatch: %v", start["total"])
}
if end["req_id"] != "req-speech-1" {
t.Fatalf("unexpected end: %v", end)
}
var got []byte
for _, c := range chunks {
got = append(got, c...)
}
if string(got) != string(audioData) {
t.Fatalf("audio data mismatch: got %d bytes want %d", len(got), len(audioData))
}
}
// ===== PushData 对离线设备报错 =====
func TestPushDataOfflineDevice(t *testing.T) {
reg := NewRegistry()
err := reg.PushData("no-such-device", "req-x", "speech", "audio/wav", []byte{1})
if err == nil || !strings.Contains(err.Error(), "not online") {
t.Fatalf("expected not online error, got %v", err)
}
}
// ===== screensee截屏回传 + 视觉描述回调 =====
func TestScreenseeEndToEnd(t *testing.T) {
reg := NewRegistry()
token := "test-token-see"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
dev := &devicectlDevice{reg: reg}
var gotDataURL string
dev.SetSeeHandler(func(dataURL string, provider string) string {
gotDataURL = dataURL
return "屏幕上显示的是测试画面"
})
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
// 设备 hello + bindbind 需 token 才能被授权流程识别,这里直接手动授权)
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"see-dev","name":"屏幕机","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
// 设备侧循环收命令并回执(模拟 GUI screensee 实现)
go func() {
for {
op, payload, err := cli.readMsg()
if err != nil {
return
}
if op != 0x1 {
continue
}
var msg map[string]interface{}
if json.Unmarshal(payload, &msg) != nil {
continue
}
if msg["op"] == "cmd" && msg["command"] == "screensee" {
reqID, _ := msg["req_id"].(string)
fakeJPEG := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10} // JPEG magic
b64 := base64.StdEncoding.EncodeToString(fakeJPEG)
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "see-dev",
"status": "ok", "output": "data:image/jpeg;base64," + b64,
}))
}
}
}()
// agent 调用 screensee
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"})
if err != nil {
t.Fatalf("screensee: %v", err)
}
m := res.(map[string]interface{})
if m["description"] != "屏幕上显示的是测试画面" {
t.Fatalf("unexpected description: %v", m["description"])
}
if !strings.HasPrefix(gotDataURL, "data:image/jpeg;base64,") {
t.Fatalf("handler received bad dataURL: %s", gotDataURL)
}
// 客户端鉴权模式:服务端不拦截,总是转发(设备端自行决定是否执行)。
// see-dev 未声明 screensee 之外的问题,此处仅验证服务端不再因授权状态报错。
if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"}); err != nil {
t.Fatalf("server should forward regardless of authorization, got: %v", err)
}
}
// ===== computeruse鼠标/键盘控制命令下发 =====
func TestComputeruseEndToEnd(t *testing.T) {
reg := NewRegistry()
token := "test-token-cu"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
dev := &devicectlDevice{reg: reg}
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cu-dev","name":"操控机","kind":"computer","caps":["cmd","computeruse"]}}`))
cli.readHelloAckAndBind(t, token)
// 设备侧收 computeruse 命令并回执
var receivedCmd string
done := make(chan struct{})
go func() {
for {
op, payload, err := cli.readMsg()
if err != nil {
return
}
if op != 0x1 {
continue
}
var msg map[string]interface{}
if json.Unmarshal(payload, &msg) != nil {
continue
}
if msg["op"] == "cmd" && msg["cmd_type"] == "homeagent" {
receivedCmd, _ = msg["command"].(string)
reqID, _ := msg["req_id"].(string)
if strings.HasPrefix(receivedCmd, "computeruse ") {
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "cu-dev",
"status": "ok", "output": "clicked at (3009,450)",
}))
close(done)
return
}
}
}
}()
// agent 调用 computeruse结构化参数
res, err := dev.Execute("computeruse", map[string]interface{}{
"device_id": "cu-dev", "action": "click", "x": float64(3009), "y": float64(450),
})
if err != nil {
t.Fatalf("computeruse: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("device did not receive command")
}
// 验证下发的命令是合法 JSON 参数格式
payloadJSON := strings.TrimPrefix(receivedCmd, "computeruse ")
var params map[string]interface{}
if err := json.Unmarshal([]byte(payloadJSON), &params); err != nil {
t.Fatalf("command payload not valid JSON: %v (%s)", err, payloadJSON)
}
if params["action"] != "click" || params["x"] != float64(3009) || params["y"] != float64(450) {
t.Fatalf("unexpected params: %v", params)
}
if m := res.(map[string]interface{}); m["status"] != "ok" {
t.Fatalf("expected ok result: %v", m)
}
// 缺坐标应报错
if _, err := dev.Execute("computeruse", map[string]interface{}{"device_id": "cu-dev", "action": "click"}); err == nil {
t.Fatal("click without x/y should error")
}
// 未知 action 应报错
if _, err := dev.Execute("computeruse", map[string]interface{}{"device_id": "cu-dev", "action": "fly"}); err == nil {
t.Fatal("unknown action should error")
}
// type 需要 text
if _, err := dev.Execute("computeruse", map[string]interface{}{"device_id": "cu-dev", "action": "type"}); err == nil {
t.Fatal("type without text should error")
}
}
// ===== clipboardsee / clipboardsue剪切板读写 =====
func TestClipboardEndToEnd(t *testing.T) {
reg := NewRegistry()
token := "test-token-clip"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
dev := &devicectlDevice{reg: reg}
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
// 设备侧响应剪贴板命令
go func() {
for {
op, payload, err := cli.readMsg()
if err != nil {
return
}
if op != 0x1 {
continue
}
var msg map[string]interface{}
if json.Unmarshal(payload, &msg) != nil {
continue
}
if msg["op"] != "cmd" || msg["cmd_type"] != "homeagent" {
continue
}
cmd, _ := msg["command"].(string)
reqID, _ := msg["req_id"].(string)
switch {
case cmd == "clipboardsee":
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "clip-dev",
"status": "ok", "output": "https://example.com/copied-link",
}))
case strings.HasPrefix(cmd, "clipboardsue "):
written := strings.TrimPrefix(cmd, "clipboardsue ")
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "clip-dev",
"status": "ok", "output": "clipboard set: " + written,
}))
}
}
}()
t.Run("clipboardsee_returns_content", func(t *testing.T) {
res, err := dev.Execute("clipboardsee", map[string]interface{}{"device_id": "clip-dev"})
if err != nil {
t.Fatalf("clipboardsee: %v", err)
}
m := res.(map[string]interface{})
if m["content"] != "https://example.com/copied-link" {
t.Fatalf("unexpected content: %v", m["content"])
}
if m["empty"] == true {
t.Fatal("content should not be empty")
}
})
t.Run("clipboardsue_writes_text", func(t *testing.T) {
long := strings.Repeat("你好", 100) // 200 runes验证 preview 截断
res, err := dev.Execute("clipboardsue", map[string]interface{}{"device_id": "clip-dev", "text": long})
if err != nil {
t.Fatalf("clipboardsue: %v", err)
}
m := res.(map[string]interface{})
if m["written"] != len(long) {
t.Fatalf("written mismatch: %v", m["written"])
}
preview, _ := m["preview"].(string)
if !strings.HasSuffix(preview, "...") || len([]rune(preview)) > 64 {
t.Fatalf("preview should be truncated: %q", preview)
}
})
t.Run("clipboardsue_requires_text", func(t *testing.T) {
if _, err := dev.Execute("clipboardsue", map[string]interface{}{"device_id": "clip-dev"}); err == nil {
t.Fatal("missing text should error")
}
})
t.Run("server_forwards_regardless_of_authorization", func(t *testing.T) {
// 客户端鉴权模式:服务端不再拦截未授权设备,由设备端自行拒绝。
// 这里验证服务端能正常查询设备(不因授权状态报错)。
if _, ok := reg.Get("clip-dev"); !ok {
t.Fatal("device should be registered")
}
})
}
// ===== 能力矩阵caps 声明 → 工具可用性 =====
func TestCapabilityMatrix(t *testing.T) {
cases := []struct {
name string
caps []string
tool string
expected bool
}{
{"摄像头只声明camera不能screensee", []string{"camera"}, "screensee", false},
{"摄像头只声明camera可以camerasue", []string{"camera"}, "camerasue", true},
{"屏幕设备支持screensue+screensee", []string{"screen"}, "screensee", true},
{"clipboard能力含读写", []string{"clipboard"}, "clipboardsue", true},
{"精确声明computeruse", []string{"computeruse"}, "computeruse", true},
{"历史cmd视为全能力", []string{"status", "cmdrun", "deviceinfo"}, "screensee", true},
{"无任何已知能力视为全兼容", []string{}, "computeruse", true},
{"混合:有已知能力则严格匹配", []string{"camera", "screen"}, "computeruse", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := deviceSupportsTool(tc.caps, tc.tool); got != tc.expected {
t.Fatalf("deviceSupportsTool(%v, %s) = %v, want %v", tc.caps, tc.tool, got, tc.expected)
}
})
}
// 端到端:声明 camera 的设备调 screensee 应被拒绝
reg := NewRegistry()
token := "test-cap-token"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
dev := &devicectlDevice{reg: reg}
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-only","name":"纯摄像头","kind":"camera","caps":["camera"]}}`))
cli.readHelloAckAndBind(t, token)
if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil {
t.Fatal("camera-only device should not support screensee")
} else if !strings.Contains(err.Error(), "未声明 screensee 能力") {
t.Fatalf("unexpected error: %v", err)
}
}
// ===== 设备主动上报事件 → 事件回调 =====
func TestDeviceEventReport(t *testing.T) {
reg := NewRegistry()
token := "test-evt-token"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
var events []map[string]interface{}
var evtMu sync.Mutex
reg.SetEventHandler(func(deviceID string, msg map[string]interface{}) {
evtMu.Lock()
events = append(events, msg)
evtMu.Unlock()
})
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-watch","name":"监控摄像头","kind":"camera","caps":["camera"]}}`))
cli.readHelloAckAndBind(t, token)
// 设备主动上报:识别到未知人员驻留
cli.sendText(mustJSON(map[string]interface{}{
"op": "event", "device_id": "cam-watch",
"type": "unknown_person_detected",
"detail": "后门区域检测到陌生面孔驻留超过30秒",
}))
// 不带 device_id 时应回退到当前连接的设备
cli.sendText(mustJSON(map[string]interface{}{
"op": "event",
"type": "motion",
}))
deadline := time.After(3 * time.Second)
for {
evtMu.Lock()
n := len(events)
evtMu.Unlock()
if n >= 2 {
break
}
select {
case <-deadline:
t.Fatalf("expected 2 events, got %d", n)
default:
time.Sleep(20 * time.Millisecond)
}
}
evtMu.Lock()
defer evtMu.Unlock()
if events[0]["type"] != "unknown_person_detected" {
t.Fatalf("unexpected first event: %v", events[0])
}
if events[1]["type"] != "motion" {
t.Fatalf("unexpected second event: %v", events[1])
}
}
func TestWSDoesNotExposeDeviceBeforeBind(t *testing.T) {
reg := NewRegistry()
token := "prebind-token"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"prebind-dev","name":"待绑定设备","kind":"computer","caps":["cmd"]}}`))
deviceID := cli.readHelloAck(t)
if deviceID != "prebind-dev" {
t.Fatalf("unexpected device id: %s", deviceID)
}
if _, ok := reg.Get(deviceID); ok {
t.Fatal("device must not be registered before bind")
}
if reg.Online(deviceID) {
t.Fatal("device must not be online before bind")
}
if err := reg.PushJSON(deviceID, map[string]interface{}{"op": "cmd"}); err == nil {
t.Fatal("command push must fail before bind")
}
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": "prebind-result", "status": "ok",
}))
time.Sleep(20 * time.Millisecond)
if _, ok := reg.GetResult("prebind-result"); ok {
t.Fatal("result must be ignored before bind")
}
}
func TestWSRejectedBindDoesNotRegister(t *testing.T) {
reg := NewRegistry()
reg.SetAcceptToken(func(provided string) bool { return provided == "expected-token" })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, "")
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"rejected-dev","name":"拒绝设备","kind":"computer"}}`))
deviceID := cli.readHelloAck(t)
cli.sendText(mustJSON(map[string]interface{}{
"op": "bind", "device_id": deviceID, "token": "wrong-token",
}))
op, payload, err := cli.readMsg()
if err != nil {
t.Fatalf("read rejected bind_ack: %v", err)
}
if op != 0x1 {
t.Fatalf("expected rejected bind_ack text frame, got %x", op)
}
var ack map[string]interface{}
if err := json.Unmarshal(payload, &ack); err != nil {
t.Fatalf("decode rejected bind_ack: %v", err)
}
if ack["op"] != "bind_ack" || ack["ok"] != false {
t.Fatalf("expected rejected bind_ack, got %v", ack)
}
if _, ok := reg.Get(deviceID); ok {
t.Fatal("rejected device must not be registered")
}
if reg.Online(deviceID) {
t.Fatal("rejected device must not be online")
}
}
func TestWSHandshakeAuthorizationAllowsUnrelatedBindToken(t *testing.T) {
reg := NewRegistry()
transportToken := "transport-token"
reg.SetAcceptToken(func(provided string) bool { return provided == transportToken })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, transportToken)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"transport-dev","name":"代理设备","kind":"computer"}}`))
deviceID := cli.readHelloAck(t)
cli.bindDevice(t, deviceID, "unrelated-body-token")
if !reg.Online(deviceID) {
t.Fatal("handshake-authorized device should be online after bind")
}
}
func TestAwaitResultReturnsResultDeliveredBeforeWaiter(t *testing.T) {
reg := NewRegistry()
want := map[string]interface{}{"status": "ok", "value": "early"}
reg.deliverResult("early-result", want)
got, err := reg.AwaitResult("early-result", 50*time.Millisecond)
if err != nil {
t.Fatalf("await early result: %v", err)
}
if got["status"] != want["status"] || got["value"] != want["value"] {
t.Fatalf("unexpected early result: %v", got)
}
}