1import { describe, expect, it } from "vite-plus/test";
2
3import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
4
5import type { ModelOption } from "../../lib/modelOptions";
6import {
7 canCommitPendingModel,
8 favoritesFirst,
9 modelMatchesCatalogQuery,
10 pendingModelAfterPress,
11} from "./thread-settings-sheet-state";
12
13function modelOption(
14 model: string,
15 options: ReadonlyArray<ProviderOptionSelection> = [],
16): ModelOption {
17 return {
18 key: `codex:${model}`,
19 label: model,
20 subtitle: "",
21 providerKey: "codex",
22 providerLabel: "Codex",
23 providerDriver: "codex",
24 isDefault: false,
25 isLegacy: false,
26 capabilities: null,
27 selection: {
28 instanceId: ProviderInstanceId.make("codex"),
29 model,
30 options,
31 },
32 };
33}
34
35describe("thread settings sheet state", () => {
36 it("keeps favorites in catalog order ahead of other models", () => {
37 const models = [
38 modelOption("first"),
39 modelOption("second"),
40 modelOption("third"),
41 modelOption("fourth"),
42 ];
43 const favorites = new Set([models[2]!.key, models[0]!.key]);
44
45 expect(favoritesFirst(models, favorites).map((model) => model.selection.model)).toEqual([
46 "first",
47 "third",
48 "second",
49 "fourth",
50 ]);
51 expect(models.map((model) => model.selection.model)).toEqual([
52 "first",
53 "second",
54 "third",
55 "fourth",
56 ]);
57 });
58
59 it("matches visible model and provider terms", () => {
60 const model = modelOption("gpt-next");
61
62 expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "NEXT" })).toBe(true);
63 expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "codex" })).toBe(true);
64 expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "claude" })).toBe(
65 false,
66 );
67 });
68
69 it("treats whitespace-only catalog searches as empty", () => {
70 expect(
71 modelMatchesCatalogQuery({
72 model: modelOption("gpt-next"),
73 providerLabel: "Codex",
74 query: " ",
75 }),
76 ).toBe(true);
77 });
78
79 it("matches the upstream provider's display name", () => {
80 const model = {
81 ...modelOption("opencode/claude-fable-5"),
82 label: "Claude Fable 5",
83 subtitle: "OpenCode Zen",
84 };
85
86 expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: " ZEN " })).toBe(
87 true,
88 );
89 expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: "copilot" })).toBe(
90 false,
91 );
92 });
93
94 it("clears staging when the applied model is pressed", () => {
95 expect(
96 pendingModelAfterPress({
97 current: modelOption("gpt-next"),
98 pressed: modelOption("gpt-current"),
99 pressedIsApplied: true,
100 }),
101 ).toBeNull();
102 });
103
104 it("preserves staged options when the highlighted model is pressed again", () => {
105 const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);
106
107 expect(
108 pendingModelAfterPress({
109 current: pending,
110 pressed: modelOption("gpt-next"),
111 pressedIsApplied: false,
112 }),
113 ).toBe(pending);
114 });
115
116 it("stages a different model", () => {
117 const pressed = modelOption("gpt-other");
118
119 expect(
120 pendingModelAfterPress({
121 current: modelOption("gpt-next"),
122 pressed,
123 pressedIsApplied: false,
124 }),
125 ).toBe(pressed);
126 });
127
128 it("cannot select a hidden model exposed only for visibility management", () => {
129 const pending = modelOption("gpt-next");
130 expect(
131 canCommitPendingModel(pending, [
132 { providerKey: "codex", providerLabel: "Codex", models: [{ ...pending, isHidden: true }] },
133 ]),
134 ).toBe(false);
135 });
136
137 it("cannot save a staged model after sign-out removes it from the catalog", () => {
138 const pending = modelOption("gemini-native");
139 const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] };
140
141 expect(canCommitPendingModel(pending, [group])).toBe(true);
142 expect(canCommitPendingModel(pending, [])).toBe(false);
143 expect(
144 canCommitPendingModel(pending, [
145 {
146 ...group,
147 models: [{ ...pending, isUnavailable: true }],
148 },
149 ]),
150 ).toBe(false);
151 });
152});