list_recent_test.go

  1package models
  2
  3import (
  4	"encoding/json"
  5	"io/fs"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10
 11	tea "charm.land/bubbletea/v2"
 12	"git.secluded.site/crush/internal/config"
 13	"git.secluded.site/crush/internal/log"
 14	"git.secluded.site/crush/internal/tui/exp/list"
 15	"github.com/charmbracelet/catwalk/pkg/catwalk"
 16	"github.com/stretchr/testify/require"
 17)
 18
 19// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
 20func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) {
 21	t.Helper()
 22	for cmd != nil {
 23		msg := cmd()
 24		var next tea.Cmd
 25		_, next = m.Update(msg)
 26		cmd = next
 27	}
 28}
 29
 30// readConfigJSON reads and unmarshals the JSON config file at path.
 31func readConfigJSON(t *testing.T, path string) map[string]any {
 32	t.Helper()
 33	baseDir := filepath.Dir(path)
 34	fileName := filepath.Base(path)
 35	b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
 36	require.NoError(t, err)
 37	var out map[string]any
 38	require.NoError(t, json.Unmarshal(b, &out))
 39	return out
 40}
 41
 42// readRecentModels reads the recent_models section from the config file.
 43func readRecentModels(t *testing.T, path string) map[string]any {
 44	t.Helper()
 45	out := readConfigJSON(t, path)
 46	rm, ok := out["recent_models"].(map[string]any)
 47	require.True(t, ok)
 48	return rm
 49}
 50
 51func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) {
 52	// Pre-initialize logger to os.DevNull to prevent file lock on Windows.
 53	log.Setup(os.DevNull, false)
 54
 55	// Isolate config/data paths
 56	cfgDir := t.TempDir()
 57	dataDir := t.TempDir()
 58	t.Setenv("XDG_CONFIG_HOME", cfgDir)
 59	t.Setenv("XDG_DATA_HOME", dataDir)
 60
 61	// Pre-seed config so provider auto-update is disabled and we have recents
 62	confPath := filepath.Join(cfgDir, "crush", "crush.json")
 63	require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
 64	initial := map[string]any{
 65		"options": map[string]any{
 66			"disable_provider_auto_update": true,
 67		},
 68		"models": map[string]any{
 69			"large": map[string]any{
 70				"model":    "m1",
 71				"provider": "p1",
 72			},
 73		},
 74		"recent_models": map[string]any{
 75			"large": []any{
 76				map[string]any{"model": "m2", "provider": "p1"},              // valid
 77				map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned
 78			},
 79		},
 80	}
 81	bts, err := json.Marshal(initial)
 82	require.NoError(t, err)
 83	require.NoError(t, os.WriteFile(confPath, bts, 0o644))
 84
 85	// Also create empty providers.json to prevent loading real providers
 86	dataConfDir := filepath.Join(dataDir, "crush")
 87	require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
 88	emptyProviders := []byte("[]")
 89	require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
 90
 91	// Initialize global config instance (no network due to auto-update disabled)
 92	_, err = config.Init(cfgDir, dataDir, false)
 93	require.NoError(t, err)
 94
 95	// Build a small provider set for the list component
 96	provider := catwalk.Provider{
 97		ID:   catwalk.InferenceProvider("p1"),
 98		Name: "Provider One",
 99		Models: []catwalk.Model{
100			{ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
101			{ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent
102		},
103	}
104
105	// Create and initialize the component with our provider set
106	listKeyMap := list.DefaultKeyMap()
107	cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
108	cmp.providers = []catwalk.Provider{provider}
109	execCmdML(t, cmp, cmp.Init())
110
111	// Find all recent items (IDs prefixed with "recent::") and verify pruning
112	groups := cmp.list.Groups()
113	require.NotEmpty(t, groups)
114	var recentItems []list.CompletionItem[ModelOption]
115	for _, g := range groups {
116		for _, it := range g.Items {
117			if strings.HasPrefix(it.ID(), "recent::") {
118				recentItems = append(recentItems, it)
119			}
120		}
121	}
122	require.NotEmpty(t, recentItems, "no recent items found")
123	// Ensure the valid recent (p1:m2) is present and the invalid one is not
124	foundValid := false
125	for _, it := range recentItems {
126		if it.ID() == "recent::p1:m2" {
127			foundValid = true
128		}
129		require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned")
130	}
131	require.True(t, foundValid, "expected valid recent not found")
132
133	// Verify original config in cfgDir remains unchanged
134	origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
135	afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
136	require.NoError(t, err)
137	var origParsed map[string]any
138	require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
139	origRM := origParsed["recent_models"].(map[string]any)
140	origLarge := origRM["large"].([]any)
141	require.Len(t, origLarge, 2, "original config should be unchanged")
142
143	// Config should be rewritten with pruned recents in dataDir
144	dataConf := filepath.Join(dataDir, "crush", "crush.json")
145	rm := readRecentModels(t, dataConf)
146	largeAny, ok := rm["large"].([]any)
147	require.True(t, ok)
148	// Ensure that only valid recent(s) remain and the invalid one is removed
149	found := false
150	for _, v := range largeAny {
151		m := v.(map[string]any)
152		require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned")
153		if m["provider"] == "p1" && m["model"] == "m2" {
154			found = true
155		}
156	}
157	require.True(t, found, "persisted recents should include p1:m2")
158}
159
160func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) {
161	// Pre-initialize logger to os.DevNull to prevent file lock on Windows.
162	log.Setup(os.DevNull, false)
163
164	// Isolate config/data paths
165	cfgDir := t.TempDir()
166	dataDir := t.TempDir()
167	t.Setenv("XDG_CONFIG_HOME", cfgDir)
168	t.Setenv("XDG_DATA_HOME", dataDir)
169
170	// Pre-seed config with valid provider but one invalid model
171	confPath := filepath.Join(cfgDir, "crush", "crush.json")
172	require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
173	initial := map[string]any{
174		"options": map[string]any{
175			"disable_provider_auto_update": true,
176		},
177		"models": map[string]any{
178			"large": map[string]any{
179				"model":    "m1",
180				"provider": "p1",
181			},
182		},
183		"recent_models": map[string]any{
184			"large": []any{
185				map[string]any{"model": "m1", "provider": "p1"},      // valid
186				map[string]any{"model": "missing", "provider": "p1"}, // invalid model
187			},
188		},
189	}
190	bts, err := json.Marshal(initial)
191	require.NoError(t, err)
192	require.NoError(t, os.WriteFile(confPath, bts, 0o644))
193
194	// Create empty providers.json
195	dataConfDir := filepath.Join(dataDir, "crush")
196	require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
197	emptyProviders := []byte("[]")
198	require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
199
200	// Initialize global config instance
201	_, err = config.Init(cfgDir, dataDir, false)
202	require.NoError(t, err)
203
204	// Build provider set that only includes m1, not "missing"
205	provider := catwalk.Provider{
206		ID:   catwalk.InferenceProvider("p1"),
207		Name: "Provider One",
208		Models: []catwalk.Model{
209			{ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
210		},
211	}
212
213	// Create and initialize component
214	listKeyMap := list.DefaultKeyMap()
215	cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
216	cmp.providers = []catwalk.Provider{provider}
217	execCmdML(t, cmp, cmp.Init())
218
219	// Find all recent items
220	groups := cmp.list.Groups()
221	require.NotEmpty(t, groups)
222	var recentItems []list.CompletionItem[ModelOption]
223	for _, g := range groups {
224		for _, it := range g.Items {
225			if strings.HasPrefix(it.ID(), "recent::") {
226				recentItems = append(recentItems, it)
227			}
228		}
229	}
230	require.NotEmpty(t, recentItems, "valid recent should exist")
231
232	// Verify the valid recent is present and invalid model is not
233	foundValid := false
234	for _, it := range recentItems {
235		if it.ID() == "recent::p1:m1" {
236			foundValid = true
237		}
238		require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned")
239	}
240	require.True(t, foundValid, "valid recent p1:m1 should be present")
241
242	// Verify original config in cfgDir remains unchanged
243	origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
244	afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
245	require.NoError(t, err)
246	var origParsed map[string]any
247	require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
248	origRM := origParsed["recent_models"].(map[string]any)
249	origLarge := origRM["large"].([]any)
250	require.Len(t, origLarge, 2, "original config should be unchanged")
251
252	// Config should be rewritten with pruned recents in dataDir
253	dataConf := filepath.Join(dataDir, "crush", "crush.json")
254	rm := readRecentModels(t, dataConf)
255	largeAny, ok := rm["large"].([]any)
256	require.True(t, ok)
257	require.Len(t, largeAny, 1, "should only have one valid model")
258	// Verify only p1:m1 remains
259	m := largeAny[0].(map[string]any)
260	require.Equal(t, "p1", m["provider"])
261	require.Equal(t, "m1", m["model"])
262}
263
264func TestModelKey_EmptyInputs(t *testing.T) {
265	// Empty provider
266	require.Equal(t, "", modelKey("", "model"))
267	// Empty model
268	require.Equal(t, "", modelKey("provider", ""))
269	// Both empty
270	require.Equal(t, "", modelKey("", ""))
271	// Valid inputs
272	require.Equal(t, "p:m", modelKey("p", "m"))
273}
274
275func TestModelList_AllRecentsInvalid(t *testing.T) {
276	// Pre-initialize logger to os.DevNull to prevent file lock on Windows.
277	log.Setup(os.DevNull, false)
278
279	// Isolate config/data paths
280	cfgDir := t.TempDir()
281	dataDir := t.TempDir()
282	t.Setenv("XDG_CONFIG_HOME", cfgDir)
283	t.Setenv("XDG_DATA_HOME", dataDir)
284
285	// Pre-seed config with only invalid recents
286	confPath := filepath.Join(cfgDir, "crush", "crush.json")
287	require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
288	initial := map[string]any{
289		"options": map[string]any{
290			"disable_provider_auto_update": true,
291		},
292		"models": map[string]any{
293			"large": map[string]any{
294				"model":    "m1",
295				"provider": "p1",
296			},
297		},
298		"recent_models": map[string]any{
299			"large": []any{
300				map[string]any{"model": "x", "provider": "unknown1"},
301				map[string]any{"model": "y", "provider": "unknown2"},
302			},
303		},
304	}
305	bts, err := json.Marshal(initial)
306	require.NoError(t, err)
307	require.NoError(t, os.WriteFile(confPath, bts, 0o644))
308
309	// Also create empty providers.json and data config
310	dataConfDir := filepath.Join(dataDir, "crush")
311	require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
312	emptyProviders := []byte("[]")
313	require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
314
315	// Initialize global config instance with isolated dataDir
316	_, err = config.Init(cfgDir, dataDir, false)
317	require.NoError(t, err)
318
319	// Build provider set (doesn't include unknown1 or unknown2)
320	provider := catwalk.Provider{
321		ID:   catwalk.InferenceProvider("p1"),
322		Name: "Provider One",
323		Models: []catwalk.Model{
324			{ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
325		},
326	}
327
328	// Create and initialize component
329	listKeyMap := list.DefaultKeyMap()
330	cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
331	cmp.providers = []catwalk.Provider{provider}
332	execCmdML(t, cmp, cmp.Init())
333
334	// Verify no recent items exist in UI
335	groups := cmp.list.Groups()
336	require.NotEmpty(t, groups)
337	var recentItems []list.CompletionItem[ModelOption]
338	for _, g := range groups {
339		for _, it := range g.Items {
340			if strings.HasPrefix(it.ID(), "recent::") {
341				recentItems = append(recentItems, it)
342			}
343		}
344	}
345	require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section")
346
347	// Verify original config in cfgDir remains unchanged
348	origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
349	afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
350	require.NoError(t, err)
351	var origParsed map[string]any
352	require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
353	origRM := origParsed["recent_models"].(map[string]any)
354	origLarge := origRM["large"].([]any)
355	require.Len(t, origLarge, 2, "original config should be unchanged")
356
357	// Config should be rewritten with empty recents in dataDir
358	dataConf := filepath.Join(dataDir, "crush", "crush.json")
359	rm := readRecentModels(t, dataConf)
360	// When all recents are pruned, the value may be nil or an empty array
361	largeVal := rm["large"]
362	if largeVal == nil {
363		// nil is acceptable - means empty
364		return
365	}
366	largeAny, ok := largeVal.([]any)
367	require.True(t, ok, "large key should be nil or array")
368	require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries")
369}