feat(web): polish master chat with model selector, params config, and empty state (#519)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #519.
This commit is contained in:
293
apps/web/src/components/chat/ChatInput.test.tsx
Normal file
293
apps/web/src/components/chat/ChatInput.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @file ChatInput.test.tsx
|
||||
* @description Tests for ChatInput: model selector, temperature/params, localStorage persistence
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
ChatInput,
|
||||
AVAILABLE_MODELS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
} from "./ChatInput";
|
||||
|
||||
// Mock fetch for version.json
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Not found"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
/** Get the first non-default model from the list */
|
||||
function getNonDefaultModel(): (typeof AVAILABLE_MODELS)[number] {
|
||||
const model = AVAILABLE_MODELS.find((m) => m.id !== DEFAULT_MODEL);
|
||||
if (!model) throw new Error("No non-default model found");
|
||||
return model;
|
||||
}
|
||||
|
||||
describe("ChatInput — model selector", () => {
|
||||
it("should render the model selector chip showing the default model", () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
const defaultLabel =
|
||||
AVAILABLE_MODELS.find((m) => m.id === DEFAULT_MODEL)?.label ?? DEFAULT_MODEL;
|
||||
expect(screen.getByText(defaultLabel)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should open the model dropdown when the chip is clicked", () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
const chip = screen.getByLabelText(/model:/i);
|
||||
fireEvent.click(chip);
|
||||
|
||||
// The dropdown (listbox role) should be visible
|
||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||
expect(listbox).toBeDefined();
|
||||
|
||||
// All model options should appear in the dropdown
|
||||
const options = within(listbox).getAllByRole("option");
|
||||
expect(options.length).toBe(AVAILABLE_MODELS.length);
|
||||
});
|
||||
|
||||
it("should call onModelChange when a model is selected", async () => {
|
||||
const onModelChange = vi.fn();
|
||||
render(<ChatInput onSend={vi.fn()} onModelChange={onModelChange} />);
|
||||
|
||||
const chip = screen.getByLabelText(/model:/i);
|
||||
fireEvent.click(chip);
|
||||
|
||||
const targetModel = getNonDefaultModel();
|
||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||
const targetOption = within(listbox).getByText(targetModel.label);
|
||||
fireEvent.click(targetOption);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = onModelChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(calls).toContain(targetModel.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should persist the selected model in localStorage", async () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
const chip = screen.getByLabelText(/model:/i);
|
||||
fireEvent.click(chip);
|
||||
|
||||
const targetModel = getNonDefaultModel();
|
||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||
const targetOption = within(listbox).getByText(targetModel.label);
|
||||
fireEvent.click(targetOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("chat:selectedModel")).toBe(targetModel.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should restore the model from localStorage on mount", async () => {
|
||||
const targetModel = getNonDefaultModel();
|
||||
localStorage.setItem("chat:selectedModel", targetModel.id);
|
||||
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(targetModel.label)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should close the dropdown after selecting a model", async () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
const chip = screen.getByLabelText(/model:/i);
|
||||
fireEvent.click(chip);
|
||||
|
||||
const targetModel = getNonDefaultModel();
|
||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||
const targetOption = within(listbox).getByText(targetModel.label);
|
||||
fireEvent.click(targetOption);
|
||||
|
||||
// After selection, dropdown should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should have aria-expanded on the model chip button", () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
const chip = screen.getByLabelText(/model:/i);
|
||||
expect(chip.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(chip);
|
||||
expect(chip.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInput — temperature and max tokens", () => {
|
||||
it("should render the settings/params button", () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
const settingsBtn = screen.getByLabelText(/chat parameters/i);
|
||||
expect(settingsBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("should open the params popover when settings button is clicked", () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
const settingsBtn = screen.getByLabelText(/chat parameters/i);
|
||||
fireEvent.click(settingsBtn);
|
||||
|
||||
expect(screen.getByLabelText(/temperature/i)).toBeDefined();
|
||||
expect(screen.getByLabelText(/maximum tokens/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should show the default temperature value", () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||
|
||||
const slider = screen.getByLabelText(/temperature/i);
|
||||
expect(parseFloat((slider as HTMLInputElement).value)).toBeCloseTo(DEFAULT_TEMPERATURE);
|
||||
});
|
||||
|
||||
it("should call onTemperatureChange when the slider is moved", async () => {
|
||||
const onTemperatureChange = vi.fn();
|
||||
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||
|
||||
const slider = screen.getByLabelText(/temperature/i);
|
||||
fireEvent.change(slider, { target: { value: "1.2" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(calls).toContain(1.2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should persist temperature in localStorage", async () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||
|
||||
const slider = screen.getByLabelText(/temperature/i);
|
||||
fireEvent.change(slider, { target: { value: "0.5" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("chat:temperature")).toBe("0.5");
|
||||
});
|
||||
});
|
||||
|
||||
it("should restore temperature from localStorage on mount", async () => {
|
||||
localStorage.setItem("chat:temperature", "1.5");
|
||||
|
||||
const onTemperatureChange = vi.fn();
|
||||
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(calls).toContain(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the default max tokens value", () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||
|
||||
const input = screen.getByLabelText(/maximum tokens/i);
|
||||
expect(parseInt((input as HTMLInputElement).value, 10)).toBe(DEFAULT_MAX_TOKENS);
|
||||
});
|
||||
|
||||
it("should call onMaxTokensChange when the max tokens input changes", async () => {
|
||||
const onMaxTokensChange = vi.fn();
|
||||
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||
|
||||
const input = screen.getByLabelText(/maximum tokens/i);
|
||||
fireEvent.change(input, { target: { value: "8192" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(calls).toContain(8192);
|
||||
});
|
||||
});
|
||||
|
||||
it("should persist max tokens in localStorage", async () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||
|
||||
const input = screen.getByLabelText(/maximum tokens/i);
|
||||
fireEvent.change(input, { target: { value: "2000" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("chat:maxTokens")).toBe("2000");
|
||||
});
|
||||
});
|
||||
|
||||
it("should restore max tokens from localStorage on mount", async () => {
|
||||
localStorage.setItem("chat:maxTokens", "8000");
|
||||
|
||||
const onMaxTokensChange = vi.fn();
|
||||
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(calls).toContain(8000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInput — externalValue (suggestion fill)", () => {
|
||||
it("should update the textarea when externalValue is provided", async () => {
|
||||
const { rerender } = render(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByLabelText(/message input/i);
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
|
||||
rerender(<ChatInput onSend={vi.fn()} externalValue="Hello suggestion" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Hello suggestion");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInput — send behavior", () => {
|
||||
it("should call onSend with the message when the send button is clicked", async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
|
||||
const textarea = screen.getByLabelText(/message input/i);
|
||||
fireEvent.change(textarea, { target: { value: "Hello world" } });
|
||||
|
||||
const sendButton = screen.getByLabelText(/send message/i);
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSend).toHaveBeenCalledWith("Hello world");
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear the textarea after sending", async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
|
||||
const textarea = screen.getByLabelText(/message input/i);
|
||||
fireEvent.change(textarea, { target: { value: "Hello world" } });
|
||||
fireEvent.click(screen.getByLabelText(/send message/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the stop button when streaming", () => {
|
||||
render(<ChatInput onSend={vi.fn()} isStreaming={true} />);
|
||||
expect(screen.getByLabelText(/stop generating/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should call onStopStreaming when stop button is clicked", () => {
|
||||
const onStop = vi.fn();
|
||||
render(<ChatInput onSend={vi.fn()} isStreaming={true} onStopStreaming={onStop} />);
|
||||
fireEvent.click(screen.getByLabelText(/stop generating/i));
|
||||
expect(onStop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user