1package modes
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "strings"
8 "testing"
9
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"
12 "github.com/bnema/zut/packages/tui"
13)
14
15type profileSettingsStore struct {
16 SettingsStore
17 profiles map[int]QuickModelShortcut
18 active int
19 err error
20}
21
22func (s *profileSettingsStore) SetModelProfile(slot int, p QuickModelShortcut, active int) error {
23 if s.err != nil {
24 return s.err
25 }
26 s.profiles[slot], s.active = p, active
27 return nil
28}
29
30func newProfileInteractive(t *testing.T) (*Interactive, *profileSettingsStore) {
31 t.Helper()
32 s := &profileSettingsStore{profiles: make(map[int]QuickModelShortcut)}
33 i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s,
34 QuickModelShortcuts: []QuickModelShortcut{{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"}},
35 })
36 i.rend = nil
37 i.agent = core.NewAgent(nil, i.cfg.Model, "", nil)
38 i.agent.Reasoning = i.cfg.Reasoning
39 return i, s
40}
41
42func selectProfileModel(t *testing.T, i *Interactive, prov, model string) {
43 t.Helper()
44 m, err := provider.FindModel(prov, model)
45 if err != nil {
46 t.Fatal(err)
47 }
48 i.modelDialog.view = []provider.Model{m}
49 i.modelDialog.cursor = 0
50 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
51}
52
53func TestModelProfileRecallAndAutosave(t *testing.T) {
54 i, s := newProfileInteractive(t)
55 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '&', Ctrl: true})
56 if i.cfg.ActiveModelProfile != 1 || s.active != 1 || s.profiles[1].Reasoning != "high" {
57 t.Fatalf("capture: cfg=%+v store=%+v", i.cfg.QuickModelShortcuts, s)
58 }
59 if i.modelDialog.Active() || !strings.Contains(i.statusOK, "profile 1 active") {
60 t.Fatal(i.statusOK)
61 }
62 i.applyReasoningSetting("max")
63 if s.profiles[1].Reasoning != "max" {
64 t.Fatal("reasoning not saved to active slot")
65 }
66 i.applyQuickModelShortcut(2)
67 selectProfileModel(t, i, "openai", "gpt-5.5")
68 i.applyReasoningSetting("")
69 i.applyModelSelection("openai", "gpt-5.5")
70 if s.profiles[2].Model != "gpt-5.5" || s.profiles[2].Reasoning != "" {
71 t.Fatal(s.profiles)
72 }
73 i.runSlash(context.Background(), "/profile 1")
74 if i.cfg.Model != "gpt-5.6-sol" || i.agent.Model != i.cfg.Model || i.cfg.Reasoning != "max" || i.agent.Reasoning != "max" || s.active != 1 {
75 t.Fatalf("recall failed: model=%s reasoning=%s active=%d", i.cfg.Model, i.cfg.Reasoning, s.active)
76 }
77 if s.profiles[2].Model != "gpt-5.5" {
78 t.Fatal("recall overwrote previous slot")
79 }
80}
81
82func TestModelProfileRestoresFastModePerSlot(t *testing.T) {
83 i, s := newProfileInteractive(t)
84 enabled := true
85 i.cfg.FastMode = &enabled
86 i.agent.SetFastMode(true)
87 i.cfg.QuickModelShortcuts = []QuickModelShortcut{
88 {Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", FastMode: true},
89 {Provider: "openai", Model: "gpt-5.5", FastMode: false},
90 }
91
92 i.applyQuickModelShortcut(1)
93 if !i.agent.FastModeEnabled() {
94 t.Fatal("profile 1 did not restore fast mode")
95 }
96 i.applyQuickModelShortcut(2)
97 if i.agent.FastModeEnabled() || i.cfg.FastMode == nil || *i.cfg.FastMode || s.profiles[2].FastMode {
98 t.Fatalf("profile 2 did not restore fast mode off: agent=%v cfg=%v store=%+v", i.agent.FastModeEnabled(), i.cfg.FastMode, s)
99 }
100 i.applyQuickModelShortcut(1)
101 if !i.agent.FastModeEnabled() || s.profiles[1].FastMode == false {
102 t.Fatalf("profile 1 did not restore fast mode on: agent=%v store=%+v", i.agent.FastModeEnabled(), s)
103 }
104}
105
106func TestModelProfileFastModeToggleAutosavesActiveSlot(t *testing.T) {
107 i, s := newProfileInteractive(t)
108 var changed []bool
109 i.cfg.OnFastModeChanged = func(enabled bool) { changed = append(changed, enabled) }
110 i.applyQuickModelShortcut(1)
111 changed = nil
112 i.applySettingToggle("fast_mode", true)
113 if i.cfg.FastMode == nil || !*i.cfg.FastMode || !i.agent.FastModeEnabled() || !s.profiles[1].FastMode || !reflect.DeepEqual(changed, []bool{true}) {
114 t.Fatalf("fast mode enable was not applied to active profile: cfg=%v agent=%v store=%+v", i.cfg.FastMode, i.agent.FastModeEnabled(), s)
115 }
116 i.applySettingToggle("fast_mode", false)
117 if i.cfg.FastMode == nil || *i.cfg.FastMode || i.agent.FastModeEnabled() || s.profiles[1].FastMode || !reflect.DeepEqual(changed, []bool{true, false}) {
118 t.Fatalf("fast mode disable was not applied to active profile: cfg=%v agent=%v store=%+v", i.cfg.FastMode, i.agent.FastModeEnabled(), s)
119 }
120}
121
122func TestModelProfileEmptyShortcutOpensPickerAndCancelPreservesSelection(t *testing.T) {
123 i, s := newProfileInteractive(t)
124 i.applyQuickModelShortcut(1)
125 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'é', Ctrl: true})
126 if !i.modelDialog.Active() || i.quickModelAssign != 2 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 {
127 t.Fatal("empty shortcut did not open a non-mutating picker")
128 }
129 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
130 if i.quickModelActivate || i.quickModelAssign != 0 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 {
131 t.Fatal("cancellation changed profile state")
132 }
133 i.runSlash(context.Background(), "/profile 2")
134 selectProfileModel(t, i, "openai", "gpt-5.5")
135 if i.cfg.ActiveModelProfile != 2 || s.active != 2 || s.profiles[2].Model != "gpt-5.5" || i.modelDialog.Active() {
136 t.Fatalf("picker did not save and activate: %+v", s)
137 }
138}
139
140func TestModelProfileDefaultSlotAndActiveSettingsEdits(t *testing.T) {
141 i, s := newProfileInteractive(t)
142 if i.cfg.ActiveModelProfile != 1 {
143 t.Fatal("missing active slot did not default to 1")
144 }
145 i.openQuickModelPicker(1)
146 i.modelDialog.reasoning = "low"
147 selectProfileModel(t, i, "openai", "gpt-5.5")
148 if s.active != 1 || s.profiles[1].Model != "gpt-5.5" || s.profiles[1].Reasoning != "low" || i.agent.Model != "gpt-5.5" || i.agent.Reasoning != "low" {
149 t.Fatalf("active settings edit did not apply and save: %+v", s)
150 }
151 fresh := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s})
152 fresh.rend = nil
153 if fresh.cfg.ActiveModelProfile != 1 {
154 t.Fatal("fresh configuration has no default slot")
155 }
156 fresh.applyReasoningSetting("low")
157 if s.active != 1 || s.profiles[1].Model != "gpt-5.6-sol" || s.profiles[1].Reasoning != "low" {
158 t.Fatal("default slot edits not saved")
159 }
160}
161
162func TestModelProfileCrossProviderCarriesTranscriptAndUsage(t *testing.T) {
163 i, s := newProfileInteractive(t)
164 msgs := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "preserve me"}}}}
165 i.agent.SetMessages(msgs)
166 i.agent.SeedCost(provider.Usage{InputTokens: 123})
167 i.cfg.QuickModelShortcuts = []QuickModelShortcut{{Provider: "google", Model: "gemini-2.0-flash", Reasoning: "high"}}
168 var changed string
169 i.cfg.OnReasoningChanged = func(level string) { changed = level }
170 i.cfg.BuildAgentFor = func(p, m string) (*core.Agent, string, string, error) {
171 return core.NewAgent(nil, m, "", nil), p, m, nil
172 }
173 transitions := 0
174 i.cfg.SessionTransition = func(fn func()) { transitions++; fn() }
175 i.applyQuickModelShortcut(1)
176 if transitions != 1 || i.cfg.Provider != "google" || i.cfg.Reasoning != "" || changed != "" || s.profiles[1].Reasoning != "" {
177 t.Fatalf("cross-provider state: provider=%s reasoning=%s store=%+v", i.cfg.Provider, i.cfg.Reasoning, s)
178 }
179 if !reflect.DeepEqual(i.agent.Messages(), msgs) || i.agent.Cost().InputTokens != 123 {
180 t.Fatal("lost transcript or usage")
181 }
182}
183
184func TestModelProfileFailureDoesNotSelectOrOverwrite(t *testing.T) {
185 for _, failure := range []string{"busy", "unknown", "build", "save"} {
186 t.Run(failure, func(t *testing.T) {
187 i, s := newProfileInteractive(t)
188 i.applyQuickModelShortcut(1)
189 before := s.profiles[1]
190 i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "google", Model: "gemini-2.0-flash"})
191 switch failure {
192 case "busy":
193 i.busy = true
194 case "unknown":
195 i.cfg.QuickModelShortcuts[1].Model = "does-not-exist"
196 case "build":
197 i.cfg.BuildAgentFor = func(string, string) (*core.Agent, string, string, error) {
198 return nil, "", "", errors.New("build failed")
199 }
200 case "save":
201 i.cfg.QuickModelShortcuts[1] = QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: "low"}
202 s.err = errors.New("disk full")
203 }
204 i.applyQuickModelShortcut(2)
205 if s.active != 1 || s.profiles[1] != before || i.statusErr == "" {
206 t.Fatalf("failed switch changed persistence: %+v / %s", s, i.statusErr)
207 }
208 if failure != "save" && (i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high") {
209 t.Fatal("failed switch changed live profile")
210 }
211 if failure == "save" && (i.cfg.ActiveModelProfile != 0 || !strings.Contains(i.statusErr, "model switched; profile not saved")) {
212 t.Fatal("save failure misrepresents persistence")
213 }
214 })
215 }
216}
217
218func TestModelProfileSaveFailureReportsRetainedModelPersistence(t *testing.T) {
219 for _, manual := range []bool{false, true} {
220 i, s := newProfileInteractive(t)
221 i.applyQuickModelShortcut(1)
222 before := s.profiles[1]
223 i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5", Reasoning: "low"})
224 var persistedModel string
225 i.cfg.PersistModel = func(_, model string) { persistedModel = model }
226 s.err = errors.New("disk full")
227 if manual {
228 i.applyModelSelection("openai", "gpt-5.5")
229 } else {
230 i.applyQuickModelShortcut(2)
231 }
232 if persistedModel != "gpt-5.5" || i.agent.Model != persistedModel {
233 t.Fatal("live model and model/session persistence must agree")
234 }
235 if s.active != 1 || s.profiles[1] != before || i.cfg.ActiveModelProfile != 0 || i.statusOK != "" || !strings.Contains(i.statusErr, "model switched; profile not saved") {
236 t.Fatalf("failed favorite save misreported: %s", i.statusErr)
237 }
238 }
239}
240
241type legacyProfileSettingsStore struct {
242 SettingsStore
243 shortcut QuickModelShortcut
244 reasoning string
245 fastMode bool
246}
247
248func (s *legacyProfileSettingsStore) SetQuickModelShortcut(_ int, prov, model string) error {
249 s.shortcut = QuickModelShortcut{Provider: prov, Model: model}
250 return nil
251}
252func (s *legacyProfileSettingsStore) SetReasoning(level string) error {
253 s.reasoning = level
254 return nil
255}
256func (s *legacyProfileSettingsStore) SetFastMode(enabled bool) error {
257 s.fastMode = enabled
258 return nil
259}
260
261func TestModelProfileLegacySettingsStoreCompatibility(t *testing.T) {
262 s := &legacyProfileSettingsStore{}
263 i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s})
264 i.rend = nil
265 i.agent = core.NewAgent(nil, i.cfg.Model, "", nil)
266 if i.cfg.ActiveModelProfile != 0 {
267 t.Fatal("legacy store cannot persist an active profile")
268 }
269 i.applyReasoningSetting("low")
270 i.applySettingToggle("fast_mode", true)
271 i.setQuickModelProfile(2, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"})
272 i.applyQuickModelShortcut(2)
273 if s.reasoning != "low" || !s.fastMode || !i.agent.FastModeEnabled() || s.shortcut.Model != "gpt-5.5" || i.agent.Model != "gpt-5.5" || i.cfg.ActiveModelProfile != 0 {
274 t.Fatal("legacy reasoning/model-only settings no longer work")
275 }
276}
277
278func TestModelProfileAssignmentAndReasoningSaveFailure(t *testing.T) {
279 i, s := newProfileInteractive(t)
280 i.applyQuickModelShortcut(1)
281 before := i.cfg.QuickModelShortcuts[0]
282 s.err = errors.New("read-only config")
283 i.setQuickModelProfile(1, QuickModelShortcut{})
284 i.applyReasoningSetting("low")
285 if i.cfg.QuickModelShortcuts[0] != before || i.cfg.ActiveModelProfile != 1 || i.agent.Reasoning != "high" || i.cfg.Reasoning != "high" {
286 t.Fatal("save failure changed selection")
287 }
288 s.err = nil
289 i.setQuickModelProfile(1, QuickModelShortcut{})
290 if i.cfg.ActiveModelProfile != 0 || s.active != 0 || s.profiles[1].Model != "" || i.agent.Model != before.Model {
291 t.Fatal("clear should detach without changing live model")
292 }
293}
294
295func TestModelProfilePickerReasoningIsLocalUntilAssigned(t *testing.T) {
296 i, s := newProfileInteractive(t)
297 i.applyQuickModelShortcut(1)
298 i.openQuickModelPicker(2)
299 i.modelDialog.view = []provider.Model{{Provider: "openai", ID: "gpt-5.6-sol", Reasoning: true}}
300 i.modelDialog.cursor = 0
301 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyLeft})
302 if i.cfg.Reasoning != "high" || s.profiles[1].Reasoning != "high" {
303 t.Fatal("picker changed active reasoning")
304 }
305 want := i.modelDialog.reasoning
306 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
307 if s.profiles[2].Reasoning != want || i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high" {
308 t.Fatalf("assignment=%+v reasoning=%s", s, want)
309 }
310}
311
312func TestModelProfileRestoresActiveIndicatorOnlyForMatchingSelection(t *testing.T) {
313 p := QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"}
314 for _, override := range []bool{false, true} {
315 cfg := InteractiveConfig{Theme: tui.Dark, Provider: p.Provider, Model: p.Model, Reasoning: p.Reasoning, QuickModelShortcuts: []QuickModelShortcut{p}, ActiveModelProfile: 1}
316 want := 1
317 if override {
318 cfg.Model = "gpt-5.5"
319 want = 0
320 }
321 i := NewInteractive(cfg)
322 if i.cfg.ActiveModelProfile != want {
323 t.Fatalf("override=%v active=%d", override, i.cfg.ActiveModelProfile)
324 }
325 }
326}
327
328func TestModelProfileShortcutLayouts(t *testing.T) {
329 for _, row := range []string{"123456789", "&é\"'(-è_ç"} {
330 for idx, r := range []rune(row) {
331 if got := quickModelShortcutSlot(tui.Key{Kind: tui.KeyRune, Rune: r, Ctrl: true}); got != idx+1 {
332 t.Fatalf("Ctrl+%c = %d", r, got)
333 }
334 for _, key := range []tui.Key{{Kind: tui.KeyRune, Rune: r}, {Kind: tui.KeyRune, Rune: r, Ctrl: true, Alt: true}, {Kind: tui.KeyPaste, Paste: string(r)}} {
335 if quickModelShortcutSlot(key) != 0 {
336 t.Fatalf("unexpected shortcut: %+v", key)
337 }
338 }
339 }
340 }
341}
342
343func TestModelProfileCommandRejectsInvalidSlots(t *testing.T) {
344 for _, command := range []string{"/profile", "/profile 0", "/profile 10", "/profile x", "/profile 1 extra"} {
345 i, s := newProfileInteractive(t)
346 i.runSlash(context.Background(), command)
347 if !strings.Contains(i.statusErr, "usage:") || s.active != 0 {
348 t.Fatalf("%s: %s", command, i.statusErr)
349 }
350 }
351}