version_bump_test.go

  1package dialog
  2
  3import (
  4	"testing"
  5
  6	"charm.land/catwalk/pkg/catwalk"
  7	"github.com/charmbracelet/crush/internal/session"
  8	"github.com/charmbracelet/crush/internal/ui/list"
  9	"github.com/charmbracelet/crush/internal/ui/styles"
 10	"github.com/sahilm/fuzzy"
 11	"github.com/stretchr/testify/require"
 12)
 13
 14// versionedItem is the cross-cutting interface every dialog list
 15// item must satisfy under F6: every documented mutator must bump
 16// the shared version counter so the list-level memo invalidates
 17// frozen entries.
 18type versionedItem interface {
 19	list.Item
 20	Version() uint64
 21}
 22
 23// requireBump asserts that running mutate() advances the item's
 24// Version().
 25func requireBump(t *testing.T, name string, item versionedItem, mutate func()) {
 26	t.Helper()
 27	before := item.Version()
 28	mutate()
 29	after := item.Version()
 30	require.Greaterf(t, after, before, "%s must bump Version() (before=%d, after=%d)", name, before, after)
 31}
 32
 33// requireNoBump asserts that running mutate() leaves the item's
 34// Version() unchanged. Used to lock in the dedupe contract: a
 35// mutator called with a value identical to the current state must
 36// not gratuitously invalidate the list cache.
 37func requireNoBump(t *testing.T, name string, item versionedItem, mutate func()) {
 38	t.Helper()
 39	before := item.Version()
 40	mutate()
 41	after := item.Version()
 42	require.Equalf(t, before, after, "%s must NOT bump Version() when state is unchanged (before=%d, after=%d)", name, before, after)
 43}
 44
 45// equivMatch returns a fuzzy.Match whose fields and indexes are
 46// equivalent to the supplied seed but allocated as a fresh struct
 47// so callers exercise the value-equality dedupe path rather than
 48// referential equality.
 49func equivMatch(seed fuzzy.Match) fuzzy.Match {
 50	return fuzzy.Match{
 51		Str:            seed.Str,
 52		Index:          seed.Index,
 53		Score:          seed.Score,
 54		MatchedIndexes: append([]int(nil), seed.MatchedIndexes...),
 55	}
 56}
 57
 58// TestCommandItem_MutatorsBumpVersion covers F6 ยง4.5 for the
 59// commands palette items: SetFocused and SetMatch bump Version()
 60// on observable change and dedupe otherwise.
 61func TestCommandItem_MutatorsBumpVersion(t *testing.T) {
 62	t.Parallel()
 63
 64	sty := styles.CharmtonePantera()
 65	item := NewCommandItem(&sty, "id", "Title", "ctrl+t", nil)
 66
 67	requireBump(t, "SetFocused[true]", item, func() {
 68		item.SetFocused(true)
 69	})
 70	requireNoBump(t, "SetFocused[true again]", item, func() {
 71		item.SetFocused(true)
 72	})
 73	requireBump(t, "SetFocused[false]", item, func() {
 74		item.SetFocused(false)
 75	})
 76
 77	match := fuzzy.Match{
 78		Str:            "Title",
 79		Index:          0,
 80		Score:          5,
 81		MatchedIndexes: []int{0, 1, 2},
 82	}
 83	requireBump(t, "SetMatch[new]", item, func() {
 84		item.SetMatch(match)
 85	})
 86	requireNoBump(t, "SetMatch[same]", item, func() {
 87		item.SetMatch(equivMatch(match))
 88	})
 89	requireBump(t, "SetMatch[different]", item, func() {
 90		item.SetMatch(fuzzy.Match{
 91			Str:            "Title",
 92			Index:          0,
 93			Score:          5,
 94			MatchedIndexes: []int{0, 2},
 95		})
 96	})
 97}
 98
 99// TestModelItem_MutatorsBumpVersion covers F6 ยง4.5 for the model
100// picker items.
101func TestModelItem_MutatorsBumpVersion(t *testing.T) {
102	t.Parallel()
103
104	sty := styles.CharmtonePantera()
105	prov := catwalk.Provider{ID: "openai", Name: "OpenAI"}
106	model := catwalk.Model{ID: "gpt-4", Name: "GPT-4"}
107	item := NewModelItem(&sty, prov, model, ModelTypeLarge, true)
108
109	requireBump(t, "SetFocused[true]", item, func() {
110		item.SetFocused(true)
111	})
112	requireNoBump(t, "SetFocused[true again]", item, func() {
113		item.SetFocused(true)
114	})
115
116	match := fuzzy.Match{
117		Str:            "GPT-4",
118		Index:          0,
119		Score:          5,
120		MatchedIndexes: []int{0, 1, 2},
121	}
122	requireBump(t, "SetMatch[new]", item, func() {
123		item.SetMatch(match)
124	})
125	requireNoBump(t, "SetMatch[same]", item, func() {
126		item.SetMatch(equivMatch(match))
127	})
128	requireBump(t, "SetMatch[different]", item, func() {
129		item.SetMatch(fuzzy.Match{
130			Str:            "GPT-4",
131			Index:          0,
132			Score:          5,
133			MatchedIndexes: []int{1},
134		})
135	})
136}
137
138// TestSessionItem_MutatorsBumpVersion covers F6 ยง4.5 for the
139// sessions dialog items.
140func TestSessionItem_MutatorsBumpVersion(t *testing.T) {
141	t.Parallel()
142
143	sty := styles.CharmtonePantera()
144	item := &SessionItem{
145		Versioned: list.NewVersioned(),
146		Session:   session.Session{ID: "sess-1", Title: "My Session"},
147		t:         &sty,
148	}
149
150	requireBump(t, "SetFocused[true]", item, func() {
151		item.SetFocused(true)
152	})
153	requireNoBump(t, "SetFocused[true again]", item, func() {
154		item.SetFocused(true)
155	})
156
157	match := fuzzy.Match{
158		Str:            "My Session",
159		Index:          0,
160		Score:          5,
161		MatchedIndexes: []int{0, 1, 2},
162	}
163	requireBump(t, "SetMatch[new]", item, func() {
164		item.SetMatch(match)
165	})
166	requireNoBump(t, "SetMatch[same]", item, func() {
167		item.SetMatch(equivMatch(match))
168	})
169	requireBump(t, "SetMatch[different]", item, func() {
170		item.SetMatch(fuzzy.Match{
171			Str:            "My Session",
172			Index:          0,
173			Score:          5,
174			MatchedIndexes: []int{3, 4},
175		})
176	})
177}
178
179// TestReasoningItem_MutatorsBumpVersion covers F6 ยง4.5 for the
180// reasoning effort dialog items.
181func TestReasoningItem_MutatorsBumpVersion(t *testing.T) {
182	t.Parallel()
183
184	sty := styles.CharmtonePantera()
185	item := &ReasoningItem{
186		Versioned: list.NewVersioned(),
187		effort:    "medium",
188		title:     "Medium",
189		t:         &sty,
190	}
191
192	requireBump(t, "SetFocused[true]", item, func() {
193		item.SetFocused(true)
194	})
195	requireNoBump(t, "SetFocused[true again]", item, func() {
196		item.SetFocused(true)
197	})
198
199	match := fuzzy.Match{
200		Str:            "Medium",
201		Index:          0,
202		Score:          5,
203		MatchedIndexes: []int{0, 1, 2},
204	}
205	requireBump(t, "SetMatch[new]", item, func() {
206		item.SetMatch(match)
207	})
208	requireNoBump(t, "SetMatch[same]", item, func() {
209		item.SetMatch(equivMatch(match))
210	})
211	requireBump(t, "SetMatch[different]", item, func() {
212		item.SetMatch(fuzzy.Match{
213			Str:            "Medium",
214			Index:          0,
215			Score:          5,
216			MatchedIndexes: []int{2, 3},
217		})
218	})
219}