diff --git a/web/src/pages/admin-settings/AIServicesSettings.test.tsx b/web/src/pages/admin-settings/AIServicesSettings.test.tsx index 20ef43a2..9813e67f 100644 --- a/web/src/pages/admin-settings/AIServicesSettings.test.tsx +++ b/web/src/pages/admin-settings/AIServicesSettings.test.tsx @@ -1,8 +1,18 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import AIServicesSettings from "./AIServicesSettings"; +const mocks = vi.hoisted(() => ({ + checkConnection: vi.fn(), + discard: vi.fn(), + save: vi.fn(), + setValue: vi.fn(), + toastError: vi.fn(), +})); + const values: Record = { "ai.base_url": "https://text.example.test", "ai.chat_model": "chat-model", @@ -23,15 +33,17 @@ const values: Record = { "metadata_ai.on_view": "button", }; +let dirtyCount = 0; + const useSettingsFormMock = vi.fn((_options?: { keys: string[] }) => ({ isLoading: false, getValue: (key: string) => values[key] ?? "", - setValue: vi.fn(), - dirtyCount: 0, + setValue: mocks.setValue, + dirtyCount, dirtyKeys: [], isDirty: vi.fn(() => false), - save: vi.fn(), - discard: vi.fn(), + save: mocks.save, + discard: mocks.discard, isSaving: false, restartRequired: false, sensitiveConfigured: ["subtitle_ai.api_key"], @@ -47,10 +59,36 @@ vi.mock("@/hooks/queries/admin/settings", () => ({ useAdminServerSettings: () => ({ data: values }), useAdminSensitiveStatus: () => ({ data: { configured: ["ai.api_key"] } }), useUpdateServerSetting: () => ({ mutateAsync: vi.fn(), isPending: false }), - useCheckAdminSettingsConnection: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCheckAdminSettingsConnection: () => ({ + mutateAsync: mocks.checkConnection, + isPending: false, + }), +})); + +vi.mock("sonner", () => ({ + toast: { + error: mocks.toastError, + }, })); describe("AIServicesSettings", () => { + beforeEach(() => { + dirtyCount = 0; + mocks.checkConnection.mockReset(); + mocks.discard.mockReset(); + mocks.save.mockReset(); + mocks.setValue.mockReset(); + mocks.toastError.mockReset(); + values["ai.base_url"] = "https://text.example.test"; + values["ai.chat_model"] = "chat-model"; + values["ai.asr_base_url"] = ""; + values["ai.asr_model"] = "whisper-model"; + values["ai.max_concurrent_jobs"] = "2"; + values["subtitle_ai.batch_size"] = "40"; + values["subtitle_ai.context_neighbors"] = "2"; + values["subtitle_ai.asr_chunk_seconds"] = "600"; + }); + it("separates text translation from speech-to-text configuration", () => { const markup = renderToStaticMarkup(); @@ -113,4 +151,84 @@ describe("AIServicesSettings", () => { expect(markup).toContain('href="/admin/recommendations"'); expect(markup).not.toContain("Changes take effect after a server restart"); }); + + it("applies a transcription preset", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Groq - fast" })); + + expect(mocks.setValue).toHaveBeenCalledWith("ai.asr_base_url", "https://api.groq.com/openai"); + expect(mocks.setValue).toHaveBeenCalledWith("ai.asr_model", "whisper-large-v3-turbo"); + }); + + it("runs both connection checks and clears their results when drafts are discarded", async () => { + const user = userEvent.setup(); + dirtyCount = 1; + mocks.checkConnection + .mockResolvedValueOnce({ success: true, message: "Text connection verified." }) + .mockResolvedValueOnce({ success: true, message: "Speech connection verified." }); + render(); + + await user.click(screen.getByRole("button", { name: "Test Text AI" })); + await user.click(screen.getByRole("button", { name: "Test Speech-to-Text" })); + expect(await screen.findByText("Text connection verified.")).toBeInTheDocument(); + expect(await screen.findByText("Speech connection verified.")).toBeInTheDocument(); + expect(mocks.checkConnection).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ kind: "ai_chat" }), + ); + expect(mocks.checkConnection).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ kind: "ai_transcription" }), + ); + + await user.click(screen.getByRole("button", { name: "Discard" })); + + expect(mocks.discard).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(screen.queryByText("Text connection verified.")).not.toBeInTheDocument(); + expect(screen.queryByText("Speech connection verified.")).not.toBeInTheDocument(); + }); + }); + + it("clears a prior connection result when its endpoint changes", async () => { + const user = userEvent.setup(); + mocks.checkConnection.mockResolvedValue({ + success: true, + message: "Text connection verified.", + }); + render(); + + await user.click(screen.getByRole("button", { name: "Test Text AI" })); + expect(await screen.findByText("Text connection verified.")).toBeInTheDocument(); + await user.clear(screen.getByRole("textbox", { name: "Base URL" })); + + expect(screen.queryByText("Text connection verified.")).not.toBeInTheDocument(); + }); + + it.each([ + ["ai.max_concurrent_jobs", "1.5", "Max concurrent jobs must be a positive whole number."], + ["subtitle_ai.batch_size", "2abc", "Subtitle batch size must be a positive whole number."], + [ + "subtitle_ai.context_neighbors", + "1.5", + "Subtitle context lines must be zero or a positive whole number.", + ], + [ + "subtitle_ai.asr_chunk_seconds", + "120seconds", + "Transcription chunk length must be between 60 and 600 seconds.", + ], + ])("rejects malformed integer input for %s", async (key, malformedValue, message) => { + const user = userEvent.setup(); + dirtyCount = 1; + values[key] = malformedValue; + render(); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + expect(mocks.toastError).toHaveBeenCalledWith(message); + expect(mocks.save).not.toHaveBeenCalled(); + }); }); diff --git a/web/src/pages/admin-settings/AIServicesSettings.tsx b/web/src/pages/admin-settings/AIServicesSettings.tsx index 596dc0a5..79516773 100644 --- a/web/src/pages/admin-settings/AIServicesSettings.tsx +++ b/web/src/pages/admin-settings/AIServicesSettings.tsx @@ -102,6 +102,13 @@ function isChatOnlyGateway(rawURL: string): boolean { } } +function parseStrictInteger(rawValue: string): number | null { + const trimmed = rawValue.trim(); + if (!/^-?\d+$/.test(trimmed)) return null; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : null; +} + function SectionHeading({ icon: Icon, title, @@ -250,32 +257,31 @@ export default function AIServicesSettings() { } async function save() { - const batchSize = Number.parseInt(value("subtitle_ai.batch_size", "40"), 10); - const contextLines = Number.parseInt(value("subtitle_ai.context_neighbors", "2"), 10); - const chunkSeconds = Number.parseInt(value("subtitle_ai.asr_chunk_seconds", "600"), 10); + const batchSize = parseStrictInteger(value("subtitle_ai.batch_size", "40")); + const contextLines = parseStrictInteger(value("subtitle_ai.context_neighbors", "2")); + const chunkSeconds = parseStrictInteger(value("subtitle_ai.asr_chunk_seconds", "600")); const quotaJobs = Number.parseInt(value("subtitle_ai.transcribe_quota_jobs", "0"), 10); - const maxConcurrent = Number.parseInt( + const maxConcurrent = parseStrictInteger( effectiveValue("ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs", "2"), - 10, ); if (!textReady) { toast.error("Text AI base URL and chat model are required."); return; } - if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) { + if (maxConcurrent === null || maxConcurrent < 1) { toast.error("Max concurrent jobs must be a positive whole number."); return; } - if (!Number.isInteger(batchSize) || batchSize < 1) { + if (batchSize === null || batchSize < 1) { toast.error("Subtitle batch size must be a positive whole number."); return; } - if (!Number.isInteger(contextLines) || contextLines < 0) { + if (contextLines === null || contextLines < 0) { toast.error("Subtitle context lines must be zero or a positive whole number."); return; } - if (!Number.isInteger(chunkSeconds) || chunkSeconds < 60 || chunkSeconds > 600) { + if (chunkSeconds === null || chunkSeconds < 60 || chunkSeconds > 600) { toast.error("Transcription chunk length must be between 60 and 600 seconds."); return; } @@ -286,6 +292,12 @@ export default function AIServicesSettings() { await form.save(); } + function discard() { + form.discard(); + setTextResult(null); + setSpeechResult(null); + } + return (
@@ -588,7 +600,7 @@ export default function AIServicesSettings() { void save()} - onDiscard={form.discard} + onDiscard={discard} isSaving={form.isSaving} restartRequired={form.restartRequired} />