Files
next-ai-draw-io/tests/unit/use-model-config.test.tsx
T

263 lines
8.8 KiB
TypeScript
Raw Normal View History

import { act, cleanup, renderHook, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage"
import type { MultiModelConfig } from "@/lib/types/model-config"
const SERVER_MODELS: FlattenedServerModel[] = [
{
id: "server:openai-main:gpt-4o-mini",
modelId: "gpt-4o-mini",
provider: "openai",
providerLabel: "OpenAI Main",
isDefault: false,
},
{
id: "server:openai-main:gpt-4o",
modelId: "gpt-4o",
provider: "openai",
providerLabel: "OpenAI Main",
isDefault: true,
},
]
const USER_CONFIG: MultiModelConfig = {
version: 1,
providers: [
{
id: "p1",
provider: "openai",
apiKey: "sk-test",
models: [{ id: "m1", modelId: "gpt-4o" }],
},
],
}
function storeConfig(config: MultiModelConfig) {
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
}
async function renderLoaded() {
const hook = renderHook(() => useModelConfig())
await waitFor(() => expect(hook.result.current.isLoaded).toBe(true))
return hook
}
beforeEach(() => {
localStorage.clear()
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ models: SERVER_MODELS }),
})),
)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe("useModelConfig server model selection", () => {
it("replaces a saved server model that no longer exists", async () => {
storeConfig({
...USER_CONFIG,
selectedModelId: "server:openai-production:gpt-4o",
})
const { result } = await renderLoaded()
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
})
it("keeps a server model saved under its old id", async () => {
// Before non-ASCII provider names got their own slug, "主力 OpenAI"
// became "openai"
const renamed: FlattenedServerModel = {
id: "server:4e3b-529b-openai:gpt-4o-mini",
modelId: "gpt-4o-mini",
provider: "openai",
providerLabel: "主力 OpenAI",
isDefault: false,
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ models: [...SERVER_MODELS, renamed] }),
})),
)
storeConfig({
...USER_CONFIG,
selectedModelId: "server:openai:gpt-4o-mini",
})
const { result } = await renderLoaded()
expect(result.current.selectedModelId).toBe(renamed.id)
})
it("keeps a saved server model that still exists", async () => {
storeConfig({
...USER_CONFIG,
selectedModelId: "server:openai-main:gpt-4o-mini",
})
const { result } = await renderLoaded()
expect(result.current.selectedModelId).toBe(
"server:openai-main:gpt-4o-mini",
)
})
it("skips a saved provider this version does not know", async () => {
// Saved by another version, or edited by hand: it used to crash the
// whole page on load
storeConfig({
...USER_CONFIG,
providers: [
...USER_CONFIG.providers,
{
id: "p9",
provider: "not-a-provider" as any,
apiKey: "k",
models: [{ id: "m9", modelId: "x" }],
},
],
})
const { result } = await renderLoaded()
expect(result.current.config.providers.map((p) => p.id)).toEqual(["p1"])
expect(result.current.models.map((m) => m.id)).toContain("m1")
})
it("keeps an unknown provider and its key in storage", async () => {
// The version that saved it may be opened again (an older desktop
// build, another tab): the provider must still be there
storeConfig({
...USER_CONFIG,
providers: [
...USER_CONFIG.providers,
{
id: "p9",
provider: "not-a-provider" as any,
apiKey: "k9",
models: [{ id: "m9", modelId: "x" }],
},
],
selectedModelId: "m1",
})
const { result } = await renderLoaded()
act(() => result.current.setSelectedModelId(undefined))
await waitFor(() => {
const stored = JSON.parse(
localStorage.getItem(STORAGE_KEYS.modelConfigs) ?? "{}",
)
expect(stored.selectedModelId).toBeUndefined()
expect(stored.providers.map((p: { id: string }) => p.id)).toEqual([
"p1",
"p9",
])
expect(stored.providers[1].apiKey).toBe("k9")
})
// Sending reads the stored config too, and must not trip over it
act(() => result.current.setSelectedModelId("m1"))
expect(getSelectedAIConfig()).toMatchObject({
aiProvider: "openai",
aiModel: "gpt-4o",
})
})
it("keeps a selected user model", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
expect(result.current.selectedModelId).toBe("m1")
})
it("falls back to the default server model when the selected model is deleted", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
act(() => result.current.deleteModel("p1", "m1"))
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
})
it("falls back to the default server model when the selected provider is deleted", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
act(() => result.current.deleteProvider("p1"))
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
})
})
describe("useModelConfig in the desktop app", () => {
it("reloads the server models after a preset switch restarts the server", async () => {
// The new preset offers other models; the saved one is gone
let restarted: (() => void) | undefined
;(window as any).electronAPI = {
onServerRestarted: (callback: () => void) => {
restarted = callback
return () => {
restarted = undefined
}
},
}
try {
storeConfig({
...USER_CONFIG,
selectedModelId: "server:openai-main:gpt-4o-mini",
})
const { result } = await renderLoaded()
await waitFor(() => expect(restarted).toBeDefined())
const nextModels: FlattenedServerModel[] = [
{
id: "server:claude:claude-sonnet-5-5",
modelId: "claude-sonnet-5-5",
provider: "anthropic",
providerLabel: "Claude",
isDefault: true,
},
]
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ models: nextModels }),
})),
)
act(() => restarted?.())
await waitFor(() =>
expect(result.current.selectedModelId).toBe(
"server:claude:claude-sonnet-5-5",
),
)
} finally {
delete (window as any).electronAPI
}
})
})
describe("useModelConfig across tabs", () => {
it("reloads the config when another tab saves it", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
const fromOtherTab: MultiModelConfig = {
...USER_CONFIG,
providers: [
...USER_CONFIG.providers,
{
id: "p2",
provider: "anthropic",
apiKey: "sk-ant",
models: [{ id: "m2", modelId: "claude-sonnet-4-5" }],
},
],
selectedModelId: "m2",
}
act(() => {
storeConfig(fromOtherTab)
window.dispatchEvent(
new StorageEvent("storage", { key: STORAGE_KEYS.modelConfigs }),
)
})
expect(result.current.selectedModelId).toBe("m2")
expect(result.current.config.providers).toHaveLength(2)
})
})