package cache import ( "fmt" "reflect" "sort" "sync" "testing" ) // ───────────────────────────────────────────────────────────────────────────── // 辅助函数 // ───────────────────────────────────────────────────────────────────────────── // entry 快速构建 ToolEntry(name 即 content,便于测试). func entry(name string) ToolEntry { return ToolEntry{Name: name, Content: []byte("schema:" + name)} } // entryV 构建带版本号的 ToolEntry(模拟工具描述变化). func entryV(name, version string) ToolEntry { return ToolEntry{Name: name, Content: []byte("schema:" + name + "@" + version)} } // trackN 连续追踪 n 轮同样的工具集(模拟稳定场景). func trackN(t *testing.T, tracker *ToolSchemaTracker, tools []ToolEntry, n int) { t.Helper() for i := 0; i < n; i++ { tracker.Track(tools) } } // sortedStrings 对字符串切片排序后返回(不修改原切片). func sortedStrings(s []string) []string { out := make([]string, len(s)) copy(out, s) sort.Strings(out) return out } // ───────────────────────────────────────────────────────────────────────────── // Track: 基本功能 // ───────────────────────────────────────────────────────────────────────────── func TestToolSchemaTracker_FirstTrack_AllAdded(t *testing.T) { tr := NewToolSchemaTracker() changes := tr.Track([]ToolEntry{entry("bash"), entry("read"), entry("edit")}) if changes.Stable { t.Error("first Track should not be stable (tools were added)") } if !reflect.DeepEqual(sortedStrings(changes.Added), []string{"bash", "edit", "read"}) { t.Errorf("Added = %v, want [bash edit read]", changes.Added) } if len(changes.Removed) != 0 { t.Errorf("Removed = %v, want []", changes.Removed) } if len(changes.Changed) != 0 { t.Errorf("Changed = %v, want []", changes.Changed) } } func TestToolSchemaTracker_NoChange_Stable(t *testing.T) { tr := NewToolSchemaTracker() tools := []ToolEntry{entry("bash"), entry("read")} tr.Track(tools) changes := tr.Track(tools) if !changes.Stable { t.Error("second Track with same tools should be stable") } if len(changes.Added) != 0 || len(changes.Removed) != 0 || len(changes.Changed) != 0 { t.Errorf("no changes expected, got Added=%v Removed=%v Changed=%v", changes.Added, changes.Removed, changes.Changed) } } func TestToolSchemaTracker_ToolAdded(t *testing.T) { tr := NewToolSchemaTracker() tr.Track([]ToolEntry{entry("bash")}) changes := tr.Track([]ToolEntry{entry("bash"), entry("read")}) if changes.Stable { t.Error("should not be stable when tool is added") } if !reflect.DeepEqual(changes.Added, []string{"read"}) { t.Errorf("Added = %v, want [read]", changes.Added) } } func TestToolSchemaTracker_ToolRemoved(t *testing.T) { tr := NewToolSchemaTracker() tr.Track([]ToolEntry{entry("bash"), entry("read")}) changes := tr.Track([]ToolEntry{entry("bash")}) if changes.Stable { t.Error("should not be stable when tool is removed") } if !reflect.DeepEqual(changes.Removed, []string{"read"}) { t.Errorf("Removed = %v, want [read]", changes.Removed) } } func TestToolSchemaTracker_ToolDescriptionChanged(t *testing.T) { tr := NewToolSchemaTracker() tr.Track([]ToolEntry{entryV("bash", "v1"), entry("read")}) changes := tr.Track([]ToolEntry{entryV("bash", "v2"), entry("read")}) if changes.Stable { t.Error("should not be stable when tool description changes") } if !reflect.DeepEqual(changes.Changed, []string{"bash"}) { t.Errorf("Changed = %v, want [bash]", changes.Changed) } if len(changes.Added) != 0 || len(changes.Removed) != 0 { t.Errorf("only Changed expected, got Added=%v Removed=%v", changes.Added, changes.Removed) } } func TestToolSchemaTracker_MultiplChanges_Sorted(t *testing.T) { tr := NewToolSchemaTracker() tr.Track([]ToolEntry{entryV("z_tool", "v1"), entryV("a_tool", "v1"), entryV("m_tool", "v1")}) // z 变化,a 变化,b 新增,m 删除 changes := tr.Track([]ToolEntry{ entryV("z_tool", "v2"), entryV("a_tool", "v2"), entry("b_tool"), }) if !reflect.DeepEqual(changes.Added, []string{"b_tool"}) { t.Errorf("Added = %v, want [b_tool]", changes.Added) } if !reflect.DeepEqual(changes.Removed, []string{"m_tool"}) { t.Errorf("Removed = %v, want [m_tool]", changes.Removed) } if !reflect.DeepEqual(changes.Changed, []string{"a_tool", "z_tool"}) { t.Errorf("Changed = %v, want [a_tool z_tool]", changes.Changed) } } func TestToolSchemaTracker_EmptyTools_Stable(t *testing.T) { tr := NewToolSchemaTracker() c1 := tr.Track(nil) c2 := tr.Track(nil) // 第一次:没有工具,Added 为空,视为 Stable(没有变化) if !c1.Stable { t.Errorf("first Track with nil tools: expected Stable=true, got false") } if !c2.Stable { t.Error("second Track with nil tools should be stable") } } func TestToolSchemaTracker_TurnCount(t *testing.T) { tr := NewToolSchemaTracker() if tr.TurnCount() != 0 { t.Errorf("initial TurnCount = %d, want 0", tr.TurnCount()) } tr.Track([]ToolEntry{entry("bash")}) if tr.TurnCount() != 1 { t.Errorf("TurnCount after 1 Track = %d, want 1", tr.TurnCount()) } tr.Track([]ToolEntry{entry("bash")}) if tr.TurnCount() != 2 { t.Errorf("TurnCount after 2 Tracks = %d, want 2", tr.TurnCount()) } } // ───────────────────────────────────────────────────────────────────────────── // isStable / StableFirstWithBoundary 稳定性窗口逻辑 // ───────────────────────────────────────────────────────────────────────────── func TestToolSchemaTracker_StabilityWindow_NothingStableBeforeWindow(t *testing.T) { const window = 5 tr := NewToolSchemaTrackerWithWindow(window) tools := []ToolEntry{entry("bash"), entry("read")} // window-1 轮:还未达到稳定窗口 trackN(t, tr, tools, window-1) _, stableCount := tr.StableFirstWithBoundary([]string{"bash", "read"}) if stableCount != 0 { t.Errorf("before window: stableCount = %d, want 0", stableCount) } } func TestToolSchemaTracker_StabilityWindow_AllStableAfterWindow(t *testing.T) { const window = 5 tr := NewToolSchemaTrackerWithWindow(window) tools := []ToolEntry{entry("bash"), entry("read")} // 恰好 window 轮:工具应变为稳定 trackN(t, tr, tools, window) sorted, stableCount := tr.StableFirstWithBoundary([]string{"bash", "read"}) if stableCount != 2 { t.Errorf("after window: stableCount = %d, want 2", stableCount) } // 稳定工具在前,不稳定在后(这里全部稳定) if len(sorted) != 2 { t.Errorf("sorted len = %d, want 2", len(sorted)) } } func TestToolSchemaTracker_StabilityWindow_ChangedToolBecomesUnstable(t *testing.T) { const window = 5 tr := NewToolSchemaTrackerWithWindow(window) // 让 bash 和 read 先变稳定 stable := []ToolEntry{entry("bash"), entry("read")} trackN(t, tr, stable, window) // 第 window+1 轮:bash 描述变化 tr.Track([]ToolEntry{entryV("bash", "v2"), entry("read")}) _, stableCount := tr.StableFirstWithBoundary([]string{"bash", "read"}) if stableCount != 1 { t.Errorf("after bash changed: stableCount = %d, want 1 (only read is stable)", stableCount) } // StableFirst 应把 read 排在前面 sorted, _ := tr.StableFirstWithBoundary([]string{"bash", "read"}) if len(sorted) != 2 || sorted[0] != "read" { t.Errorf("sorted = %v, want [read bash]", sorted) } } func TestToolSchemaTracker_StabilityWindow_UnstableBecomesStableAgain(t *testing.T) { const window = 3 tr := NewToolSchemaTrackerWithWindow(window) // 先稳定 window 轮 tools := []ToolEntry{entry("bash"), entry("read")} trackN(t, tr, tools, window) // bash 变化一次 tr.Track([]ToolEntry{entryV("bash", "v2"), entry("read")}) // 再稳定 window 轮(bash 又变稳定) stableTools := []ToolEntry{entryV("bash", "v2"), entry("read")} trackN(t, tr, stableTools, window) _, stableCount := tr.StableFirstWithBoundary([]string{"bash", "read"}) if stableCount != 2 { t.Errorf("after re-stabilization: stableCount = %d, want 2", stableCount) } } func TestToolSchemaTracker_StableFirstWithBoundary_OrderPreserved(t *testing.T) { const window = 2 tr := NewToolSchemaTrackerWithWindow(window) // 让 a, b, c 稳定;d 是新工具(不稳定) trackN(t, tr, []ToolEntry{entry("a"), entry("b"), entry("c")}, window) tr.Track([]ToolEntry{entry("a"), entry("b"), entry("c"), entry("d")}) // d 是新加的,不稳定 input := []string{"d", "a", "c", "b"} sorted, stableCount := tr.StableFirstWithBoundary(input) // a, b, c 应在前(稳定),d 在后(不稳定) // 稳定工具的相对顺序应与 input 中一致(a before c before b,因为 input 顺序是 d,a,c,b) if stableCount != 3 { t.Errorf("stableCount = %d, want 3", stableCount) } if len(sorted) != 4 { t.Fatalf("sorted len = %d, want 4", len(sorted)) } if sorted[3] != "d" { t.Errorf("unstable tool 'd' should be last, got sorted=%v", sorted) } // d 必须在后面 for i := 0; i < stableCount; i++ { if sorted[i] == "d" { t.Errorf("unstable 'd' found at position %d (should be >= %d)", i, stableCount) } } } // ───────────────────────────────────────────────────────────────────────────── // StabilityReport // ───────────────────────────────────────────────────────────────────────────── func TestToolSchemaTracker_StabilityReport_Empty(t *testing.T) { tr := NewToolSchemaTracker() report := tr.StabilityReport() if len(report.StableTools) != 0 || len(report.UnstableTools) != 0 { t.Errorf("empty tracker: StableTools=%v UnstableTools=%v", report.StableTools, report.UnstableTools) } if report.TurnCount != 0 { t.Errorf("TurnCount = %d, want 0", report.TurnCount) } if report.Window != defaultStabilityWindow { t.Errorf("Window = %d, want %d", report.Window, defaultStabilityWindow) } } func TestToolSchemaTracker_StabilityReport_AfterWindow(t *testing.T) { const window = 3 tr := NewToolSchemaTrackerWithWindow(window) tools := []ToolEntry{entry("bash"), entry("read"), entry("edit")} trackN(t, tr, tools, window) report := tr.StabilityReport() if len(report.StableTools) != 3 { t.Errorf("StableTools = %v, want [bash edit read]", report.StableTools) } if len(report.UnstableTools) != 0 { t.Errorf("UnstableTools = %v, want []", report.UnstableTools) } if report.TurnCount != window { t.Errorf("TurnCount = %d, want %d", report.TurnCount, window) } } func TestToolSchemaTracker_StabilityReport_Mixed(t *testing.T) { const window = 3 tr := NewToolSchemaTrackerWithWindow(window) // 先让 bash 和 read 稳定 trackN(t, tr, []ToolEntry{entry("bash"), entry("read")}, window) // 加入不稳定的 dynamic_tool tr.Track([]ToolEntry{entry("bash"), entry("read"), entryV("dynamic_tool", "v1")}) tr.Track([]ToolEntry{entry("bash"), entry("read"), entryV("dynamic_tool", "v2")}) report := tr.StabilityReport() if !reflect.DeepEqual(report.StableTools, []string{"bash", "read"}) { t.Errorf("StableTools = %v, want [bash read]", report.StableTools) } if !reflect.DeepEqual(report.UnstableTools, []string{"dynamic_tool"}) { t.Errorf("UnstableTools = %v, want [dynamic_tool]", report.UnstableTools) } } // ───────────────────────────────────────────────────────────────────────────── // Reset // ───────────────────────────────────────────────────────────────────────────── func TestToolSchemaTracker_Reset(t *testing.T) { const window = 2 tr := NewToolSchemaTrackerWithWindow(window) trackN(t, tr, []ToolEntry{entry("bash")}, window) _, stableCount := tr.StableFirstWithBoundary([]string{"bash"}) if stableCount != 1 { t.Fatalf("before reset: stableCount = %d, want 1", stableCount) } tr.Reset() if tr.TurnCount() != 0 { t.Errorf("TurnCount after Reset = %d, want 0", tr.TurnCount()) } _, stableCount = tr.StableFirstWithBoundary([]string{"bash"}) if stableCount != 0 { t.Errorf("after Reset: stableCount = %d, want 0 (tool no longer known)", stableCount) } } // ───────────────────────────────────────────────────────────────────────────── // 并发安全 // ───────────────────────────────────────────────────────────────────────────── func TestToolSchemaTracker_ConcurrentSafe(t *testing.T) { tr := NewToolSchemaTracker() var wg sync.WaitGroup const goroutines = 50 for i := 0; i < goroutines; i++ { wg.Add(1) go func(i int) { defer wg.Done() tools := []ToolEntry{ entryV("bash", fmt.Sprintf("v%d", i)), entry("read"), } tr.Track(tools) tr.StabilityReport() tr.StableFirstWithBoundary([]string{"bash", "read"}) }(i) } wg.Wait() // 只要不 panic / data race 就通过(用 -race 检测) if tr.TurnCount() != goroutines { t.Errorf("TurnCount = %d, want %d", tr.TurnCount(), goroutines) } } // ───────────────────────────────────────────────────────────────────────────── // 哈希行为验证 // ───────────────────────────────────────────────────────────────────────────── func TestToolSchemaTracker_SameContentSameName_NotChanged(t *testing.T) { tr := NewToolSchemaTracker() // 两次提交完全相同的内容,即使是不同的 []byte 实例 tr.Track([]ToolEntry{{Name: "bash", Content: []byte("same content")}}) changes := tr.Track([]ToolEntry{{Name: "bash", Content: []byte("same content")}}) if !changes.Stable { t.Error("identical content should be stable even with different []byte instances") } } func TestToolSchemaTracker_OneByteChange_Detected(t *testing.T) { tr := NewToolSchemaTracker() tr.Track([]ToolEntry{{Name: "bash", Content: []byte("description: run commands")}}) changes := tr.Track([]ToolEntry{{Name: "bash", Content: []byte("description: run commands.")}}) // 加了句号 if changes.Stable { t.Error("one-byte difference should be detected as changed") } if !reflect.DeepEqual(changes.Changed, []string{"bash"}) { t.Errorf("Changed = %v, want [bash]", changes.Changed) } } // ───────────────────────────────────────────────────────────────────────────── // 边界情况 // ───────────────────────────────────────────────────────────────────────────── func TestToolSchemaTracker_Window1_ImmediateStability(t *testing.T) { // window=1: 一次 Track 后工具就视为"稳定"(只要不是刚 firstSeen) // 但 firstSeenTurn 和 turnCount 相同时,turnCount-firstSeen = 0 < window=1,不稳定 // 需要 2 次 Track(第1次 firstSeen=1,第2次 turnCount=2,2-1=1>=1) tr := NewToolSchemaTrackerWithWindow(1) tr.Track([]ToolEntry{entry("bash")}) tr.Track([]ToolEntry{entry("bash")}) _, stableCount := tr.StableFirstWithBoundary([]string{"bash"}) if stableCount != 1 { t.Errorf("window=1: stableCount = %d, want 1 after 2 stable Tracks", stableCount) } } func TestToolSchemaTracker_StableFirstWithBoundary_EmptyInput(t *testing.T) { tr := NewToolSchemaTracker() sorted, stableCount := tr.StableFirstWithBoundary(nil) if len(sorted) != 0 || stableCount != 0 { t.Errorf("empty input: sorted=%v stableCount=%d", sorted, stableCount) } } func TestToolSchemaTracker_StableFirstWithBoundary_UnknownTools(t *testing.T) { tr := NewToolSchemaTracker() // 传入 tracker 从未见过的工具名 sorted, stableCount := tr.StableFirstWithBoundary([]string{"ghost_tool"}) if stableCount != 0 { t.Errorf("unknown tool should not be stable: stableCount = %d", stableCount) } if len(sorted) != 1 || sorted[0] != "ghost_tool" { t.Errorf("sorted = %v, want [ghost_tool]", sorted) } } func TestNewToolSchemaTrackerWithWindow_MinimumWindow(t *testing.T) { // window < 1 应该修正为 1 tr := NewToolSchemaTrackerWithWindow(0) if tr.window != 1 { t.Errorf("window = %d, want 1 for input 0", tr.window) } tr = NewToolSchemaTrackerWithWindow(-5) if tr.window != 1 { t.Errorf("window = %d, want 1 for input -5", tr.window) } }