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}