返回 DeepSeek-Reasonix
catalog_test.go
根目录 / internal / extension / catalog_test.go
1 package extension
2
3 import (
4 "testing"
5
6 "reasonix/internal/provider"
7 )
8
9 func src(scope Scope, pluginID, origin string) ContributionSource {
10 return ContributionSource{Scope: scope, PluginID: pluginID, Origin: origin}
11 }
12
13 func schemaPayload(name, desc string) provider.ToolSchema {
14 return provider.ToolSchema{Name: name, Description: desc, Parameters: []byte(`{"type":"object"}`)}
15 }
16
17 // TestCatalogSortOrder pins the documented iteration order: kind, then tier
18 // (highest first), priority (highest first), plugin ID, ID, order. The order
19 // is what makes catalog listings stable regardless of contributor
20 // registration order.
21 func TestCatalogSortOrder(t *testing.T) {
22 c := NewCatalog()
23 c.Add(
24 Contribution{Kind: KindTool, ID: "write_file", Source: src(ScopeBuiltin, "", "builtin"), Order: 0},
25 Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopeGlobal, "", "user"), Order: 0},
26 Contribution{Kind: KindTool, ID: "read_file", Source: src(ScopeProject, "", "project"), Priority: 0, Order: 0},
27 Contribution{Kind: KindTool, ID: "read_file", Source: src(ScopeBuiltin, "", "builtin"), Priority: 5, Order: 1},
28 Contribution{Kind: KindCommand, ID: "pkg:build", Source: src(ScopePlugin, "pkg", "plugin"), Order: 1},
29 )
30 all := c.All()
31 want := []struct {
32 kind ContributionKind
33 id string
34 }{
35 {KindCommand, "pkg:build"}, // plugin tier outranks global tier
36 {KindCommand, "deploy"}, // global tier
37 {KindTool, "read_file"}, // project tier first within kind
38 {KindTool, "read_file"}, // builtin tier next
39 {KindTool, "write_file"}, // same tier, read_file < write_file
40 }
41 if len(all) != len(want) {
42 t.Fatalf("All() returned %d entries, want %d", len(all), len(want))
43 }
44 for i, w := range want {
45 if all[i].Kind != w.kind || all[i].ID != w.id {
46 t.Fatalf("position %d: got (%s, %s), want (%s, %s)", i, all[i].Kind, all[i].ID, w.kind, w.id)
47 }
48 }
49 if all[2].Source.Scope != ScopeProject || all[3].Source.Scope != ScopeBuiltin {
50 t.Fatalf("tier order wrong: got %s then %s", all[2].Source.Scope, all[3].Source.Scope)
51 }
52 }
53
54 // TestCatalogAccessors copies: mutating a returned slice must not leak back
55 // into the catalog — snapshots share catalogs, so this is part of snapshot
56 // immutability.
57 func TestCatalogAccessorCopies(t *testing.T) {
58 c := NewCatalog()
59 c.Add(
60 Contribution{Kind: KindTool, ID: "a", Source: src(ScopeBuiltin, "", "builtin")},
61 Contribution{Kind: KindHook, ID: "PreToolUse#0", Source: src(ScopeProject, "", "project")},
62 )
63 all := c.All()
64 all[0].ID = "mutated"
65 if c.All()[0].ID == "mutated" {
66 t.Fatal("mutating All() result changed the catalog")
67 }
68 byKind := c.ByKind(KindTool)
69 byKind[0].ID = "mutated"
70 if c.ByKind(KindTool)[0].ID != "a" {
71 t.Fatal("mutating ByKind() result changed the catalog")
72 }
73 if got := c.Get(KindTool, "a"); len(got) != 1 {
74 t.Fatalf("Get(tool, a) = %d entries, want 1", len(got))
75 }
76 if got := c.Get(KindTool, "missing"); len(got) != 0 {
77 t.Fatalf("Get(tool, missing) = %d entries, want 0", len(got))
78 }
79 }
80
81 // TestCatalogFrozenPanics: adding to a frozen catalog is a wiring bug and
82 // must fail loudly, never silently mutate a published snapshot.
83 func TestCatalogFrozenPanics(t *testing.T) {
84 c := NewCatalog()
85 c.Add(Contribution{Kind: KindTool, ID: "a", Source: src(ScopeBuiltin, "", "builtin")})
86 c.freeze()
87 if !c.Frozen() {
88 t.Fatal("Frozen() = false after freeze")
89 }
90 defer func() {
91 if recover() == nil {
92 t.Fatal("Add on frozen catalog did not panic")
93 }
94 }()
95 c.Add(Contribution{Kind: KindTool, ID: "b", Source: src(ScopeBuiltin, "", "builtin")})
96 }
97
98 // TestCatalogConflicts covers the reporting half of the conflict rules:
99 // same-tier duplicates from different sources show up, cross-tier duplicates
100 // and additive kinds never do.
101 func TestCatalogConflicts(t *testing.T) {
102 c := NewCatalog()
103 c.Add(
104 // Same command, same plugin tier, two plugins: conflict.
105 Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopePlugin, "pa", "plugin"), Order: 0},
106 Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopePlugin, "pb", "plugin"), Order: 0},
107 // Same tool, project vs builtin: shadowing, not a conflict.
108 Contribution{Kind: KindTool, ID: "read_file", Source: src(ScopeProject, "", "project"), Order: 0},
109 Contribution{Kind: KindTool, ID: "read_file", Source: src(ScopeBuiltin, "", "builtin"), Order: 1},
110 // Hooks are additive even at the same tier from different sources.
111 Contribution{Kind: KindHook, ID: "PreToolUse#0", Source: src(ScopeProject, "", "project"), Order: 0},
112 Contribution{Kind: KindHook, ID: "PreToolUse#0", Source: src(ScopeGlobal, "", "global"), Order: 0},
113 // Same source contributing one ID twice: one source, no conflict.
114 Contribution{Kind: KindSkill, ID: "review", Source: src(ScopeGlobal, "", "user"), Order: 0},
115 Contribution{Kind: KindSkill, ID: "review", Source: src(ScopeGlobal, "", "user"), Order: 1},
116 )
117 conflicts := c.Conflicts()
118 if len(conflicts) != 1 {
119 t.Fatalf("Conflicts() = %v, want exactly the deploy conflict", conflicts)
120 }
121 got := conflicts[0]
122 if got.Kind != KindCommand || got.ID != "deploy" {
123 t.Fatalf("conflict = (%s, %s), want (command, deploy)", got.Kind, got.ID)
124 }
125 if len(got.Sources) != 2 {
126 t.Fatalf("conflict sources = %v, want both plugins", got.Sources)
127 }
128 ids := map[string]bool{}
129 for _, s := range got.Sources {
130 ids[s.PluginID] = true
131 }
132 if !ids["pa"] || !ids["pb"] {
133 t.Fatalf("conflict sources missing plugin IDs: %v", got.Sources)
134 }
135 }
136
136 lines GO