返回 DeepSeek-Reasonix
canonicalize_test.go
根目录 / internal / plugin / canonicalize_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9
10 "reasonix/internal/tool"
11 )
12
13 func TestCanonicalizeSchemaStable(t *testing.T) {
14 schema := json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}`)
15 first := canonicalizeSchema(schema)
16 second := canonicalizeSchema(first)
17 if string(first) != string(second) {
18 t.Errorf("canonicalizeSchema is not idempotent:\n first: %s\n second: %s", first, second)
19 }
20 }
21
22 func TestCanonicalizeSchemaSortsRequired(t *testing.T) {
23 schema := json.RawMessage(`{"required":["c","a","b"],"type":"object"}`)
24 result := canonicalizeSchema(schema)
25 var m map[string]any
26 json.Unmarshal(result, &m)
27 arr := m["required"].([]any)
28 if arr[0] != "a" || arr[1] != "b" || arr[2] != "c" {
29 t.Errorf("required not sorted: %v", arr)
30 }
31 }
32
33 func TestCanonicalizeSchemaPreservesEnum(t *testing.T) {
34 schema := json.RawMessage(`{"enum":["c","a","b"]}`)
35 result := canonicalizeSchema(schema)
36 var m map[string]any
37 json.Unmarshal(result, &m)
38 arr := m["enum"].([]any)
39 if arr[0] != "c" || arr[1] != "a" || arr[2] != "b" {
40 t.Errorf("enum order was changed: %v", arr)
41 }
42 }
43
44 func TestCanonicalizeSchemaSortsKeys(t *testing.T) {
45 schema := json.RawMessage(`{"z":1,"a":2,"m":3,"type":"object","properties":{}}`)
46 result := canonicalizeSchema(schema)
47 // json.Marshal sorts map keys, so verify the JSON string directly.
48 s := string(result)
49 if s != `{"a":2,"m":3,"properties":{},"type":"object","z":1}` {
50 t.Errorf("keys not sorted, got: %s", s)
51 }
52 }
53
54 func TestCanonicalizeSchemaNested(t *testing.T) {
55 schema := json.RawMessage(`{"properties":{"inner":{"type":"object","required":["b","a"]}}}`)
56 result := canonicalizeSchema(schema)
57 var m map[string]any
58 json.Unmarshal(result, &m)
59 props := m["properties"].(map[string]any)
60 inner := props["inner"].(map[string]any)
61 req := inner["required"].([]any)
62 if req[0] != "a" || req[1] != "b" {
63 t.Errorf("nested required not sorted: %v", req)
64 }
65 }
66
67 func TestCanonicalizeSchemaEquivalentOrderingMatches(t *testing.T) {
68 first := canonicalizeSchema(json.RawMessage(`{"type":"object","required":["b","a"],"properties":{"b":{"description":"bee","type":"string"},"a":{"type":"integer"}}}`))
69 second := canonicalizeSchema(json.RawMessage(`{"properties":{"a":{"type":"integer"},"b":{"type":"string","description":"bee"}},"required":["a","b"],"type":"object"}`))
70 if string(first) != string(second) {
71 t.Fatalf("equivalent schemas canonicalized differently:\n first: %s\n second: %s", first, second)
72 }
73 }
74
75 func TestRemoteToolSchemaCanonicalizesOnReturn(t *testing.T) {
76 rt := &remoteTool{schema: json.RawMessage(`{"type":"object","required":["z","a"],"properties":{"z":{"type":"string"},"a":{"type":"string"}}}`)}
77 if got, want := string(rt.Schema()), `{"properties":{"a":{"type":"string"},"z":{"type":"string"}},"required":["a","z"],"type":"object"}`; got != want {
78 t.Fatalf("Schema() = %s, want %s", got, want)
79 }
80 }
81
82 func TestSortToolsByName(t *testing.T) {
83 tools := []tool.Tool{
84 testTool{name: "zulu"},
85 testTool{name: "alpha"},
86 testTool{name: "mike"},
87 }
88 sorted := sortToolsByName(tools)
89 if sorted[0].Name() != "alpha" || sorted[1].Name() != "mike" || sorted[2].Name() != "zulu" {
90 t.Errorf("tools not sorted: %v", toolNames(sorted))
91 }
92 // Original should be unchanged
93 if tools[0].Name() != "zulu" {
94 t.Error("original slice was mutated")
95 }
96 }
97
98 func TestNormalizeNameForToolNames(t *testing.T) {
99 if got := normalizeName("valid_name-1"); got != "valid_name-1" {
100 t.Fatalf("valid name changed: %q", got)
101 }
102 cases := []string{"@modelcontextprotocol/server-memory", "mcp server/fetch", " "}
103 for _, in := range cases {
104 got := normalizeName(in)
105 if got == "" || strings.ContainsAny(got, " @/") {
106 t.Errorf("normalizeName(%q) = %q, want non-empty safe identifier", in, got)
107 }
108 }
109 }
110
111 func TestNormalizeNameAvoidsSanitizedCollisions(t *testing.T) {
112 a := normalizeName("search/code")
113 b := normalizeName("search_code")
114 if a == b {
115 t.Fatalf("normalized names collided: %q", a)
116 }
117 if b != "search_code" {
118 t.Fatalf("valid identifier should stay stable, got %q", b)
119 }
120 if normalizeName("@foo") == normalizeName("foo") {
121 t.Fatal("trimmed invalid prefix should not collapse onto valid name")
122 }
123 }
124
125 func TestSummarizeFailureErrorSingleLine(t *testing.T) {
126 got := summarizeFailureError(errors.New("npm error code ENOTEMPTY\nnpm error path /tmp/x"))
127 if strings.Contains(got, "\n") || !strings.Contains(got, "ENOTEMPTY") {
128 t.Fatalf("summary = %q", got)
129 }
130 }
131
132 type testTool struct{ name string }
133
134 func (t testTool) Name() string { return t.name }
135 func (t testTool) Description() string { return "" }
136 func (t testTool) Schema() json.RawMessage { return nil }
137 func (t testTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { return "", nil }
138 func (t testTool) ReadOnly() bool { return true }
139
140 func toolNames(ts []tool.Tool) []string {
141 names := make([]string, len(ts))
142 for i, t := range ts {
143 names[i] = t.Name()
144 }
145 return names
146 }
147
147 lines GO