返回 DeepSeek-Reasonix
model_test.go
根目录 / internal / cli / model_test.go
1 package cli
2
3 import (
4 "os"
5 "strings"
6 "testing"
7
8 tea "charm.land/bubbletea/v2"
9
10 "reasonix/internal/config"
11 "reasonix/internal/provider"
12 )
13
14 // TestModelRefsFromConfig verifies the /model picker enumerates configured
15 // provider/model refs (built-in defaults when no reasonix.toml is present), and
16 // only those whose provider API key is set.
17 func TestModelRefsFromConfig(t *testing.T) {
18 isolateUserConfig(t) // no reasonix.toml -> built-in default providers
19 // Only DeepSeek keyed → MiMo refs must be filtered out.
20 if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
21 t.Fatalf("SetCredential: %v", err)
22 }
23 t.Setenv("MIMO_API_KEY", "")
24 refs := modelRefs()
25 if len(refs) == 0 {
26 t.Fatal("expected default provider/model refs, got none")
27 }
28 for _, r := range refs {
29 if !strings.Contains(r, "/") {
30 t.Errorf("ref %q should be provider/model", r)
31 }
32 if strings.HasPrefix(r, "mimo") {
33 t.Errorf("ref %q from a provider without an API key should be filtered out", r)
34 }
35 }
36 }
37
38 func TestBareModelOpensKeyboardPicker(t *testing.T) {
39 isolateUserConfig(t)
40 if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
41 t.Fatal(err)
42 }
43 m := newTestChatTUI()
44 m.runModelSubcommand("/model")
45 if m.quickPick == nil || m.quickPick.kind != quickPickerModel {
46 t.Fatalf("bare /model picker = %+v", m.quickPick)
47 }
48 if m.renderQuickPicker() == "" || !m.hideComposer() {
49 t.Fatal("model picker should render as a modal panel")
50 }
51 next, _ := m.handleQuickPickerKey(tea.KeyPressMsg{Code: tea.KeyEsc})
52 if next.(chatTUI).quickPick != nil {
53 t.Fatal("Esc should close model picker")
54 }
55 }
56
57 // TestModelRefsSkipsUnconfigured verifies that with no provider keys set, the
58 // picker offers nothing rather than listing models the user can't select.
59 func TestModelRefsSkipsUnconfigured(t *testing.T) {
60 isolateUserConfig(t)
61 t.Setenv("DEEPSEEK_API_KEY", "")
62 t.Setenv("MIMO_API_KEY", "")
63 if refs := modelRefs(); len(refs) != 0 {
64 t.Errorf("no keys set → no refs, got %v", refs)
65 }
66 }
67
68 // TestModelArgCompletion verifies "/model " completes to the configured refs
69 // through the shared completion path.
70 func TestModelArgCompletion(t *testing.T) {
71 isolateUserConfig(t)
72 if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
73 t.Fatalf("SetCredential: %v", err)
74 }
75 m := newTestChatTUI()
76 items, _, ok := m.slashArgItems("/model ")
77 if !ok || len(items) == 0 {
78 t.Fatalf("/model arg completion should offer refs, ok=%v n=%d", ok, len(items))
79 }
80 }
81
82 // TestPersistModelWritesDefaultModel verifies that calling persistModel with a
83 // "provider/model" ref writes default_model = "<ref>" to the user config file
84 // in TOML form. This is the fix for the "default model resets on every launch"
85 // regression: previously /model only mutated the in-memory controller and the
86 // next startup read the global default.
87 func TestPersistModelWritesDefaultModel(t *testing.T) {
88 isolateUserConfig(t)
89 if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
90 t.Fatalf("SetCredential: %v", err)
91 }
92 t.Setenv("MIMO_API_KEY", "")
93
94 m := newTestChatTUI()
95 m.persistModel("deepseek-flash/deepseek-v4-flash")
96
97 body, err := os.ReadFile(config.UserConfigPath())
98 if err != nil {
99 t.Fatalf("read saved config: %v", err)
100 }
101 if !strings.Contains(string(body), `default_model = "deepseek-flash/deepseek-v4-flash"`) {
102 t.Fatalf("saved config missing default_model ref:\n%s", body)
103 }
104 }
105
106 // TestPersistModelRejectsUnknownRef verifies that an unresolvable ref is
107 // silently dropped (logged to slog, not pushed to the TUI notice channel)
108 // and never lands in the config file. Reason: surface a "persist failed"
109 // notice on the input box would make /model feel broken to users whose
110 // stored config doesn't list the exact model ref they picked; the in-
111 // memory switch still goes through.
112 func TestPersistModelRejectsUnknownRef(t *testing.T) {
113 isolateUserConfig(t)
114 if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
115 t.Fatalf("SetCredential: %v", err)
116 }
117
118 m := newTestChatTUI()
119 m.persistModel("ghost/never-existed")
120
121 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
122 t.Fatalf("unknown ref must not create config file, stat err=%v", err)
123 }
124 }
125
126 // TestPersistModelAcceptsPluginRef: a plugin-namespaced ref picked in /model
127 // persists like any config ref — the config catalog cannot vouch for it, but
128 // boot's merged resolver gates it at the next launch.
129 func TestPersistModelAcceptsPluginRef(t *testing.T) {
130 isolateUserConfig(t)
131 if _, err := config.SetCredential("DEEPSEEK_API_KEY", "test-key"); err != nil {
132 t.Fatalf("SetCredential: %v", err)
133 }
134
135 m := newTestChatTUI()
136 m.persistModel("plugin/demo/fake/x")
137
138 body, err := os.ReadFile(config.UserConfigPath())
139 if err != nil {
140 t.Fatalf("read saved config: %v", err)
141 }
142 if !strings.Contains(string(body), `default_model = "plugin/demo/fake/x"`) {
143 t.Fatalf("saved config missing plugin default_model ref:\n%s", body)
144 }
145 }
146
147 // TestMergeExtensionModelRefs pins the /model picker merge: extension
148 // descriptors join the config-backed list with their plugin/... refs intact,
149 // duplicates collapse, and a nil catalog leaves the base list untouched.
150 func TestMergeExtensionModelRefs(t *testing.T) {
151 base := []string{"deepseek/deepseek-v4", "mimo/mimo-v2"}
152 catalog := []provider.Descriptor{
153 {Ref: "plugin/demo/fake/x"},
154 {Ref: "plugin/demo/fake/y"},
155 {Ref: "deepseek/deepseek-v4"}, // claim-replaced duplicate must not repeat
156 {Ref: " "}, // blank entries are dropped
157 }
158 got := mergeExtensionModelRefs(base, catalog)
159 want := []string{"deepseek/deepseek-v4", "mimo/mimo-v2", "plugin/demo/fake/x", "plugin/demo/fake/y"}
160 if strings.Join(got, ",") != strings.Join(want, ",") {
161 t.Fatalf("merge = %v, want %v", got, want)
162 }
163 if out := mergeExtensionModelRefs(base, nil); strings.Join(out, ",") != strings.Join(base, ",") {
164 t.Fatalf("nil catalog changed the base list: %v", out)
165 }
166 }
167
167 lines GO