deer-flow/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx
Ryker_Feng 6ca12c6c8f
feat(models): add user model favorites (#5441)
* feat(models): add user model favorites

* fix(models): use anchored favorites picker

* fix(models): keep model picker compact

* fix(models): remove obsolete favorites search path

* fix(models): address picker review feedback
2026-09-16 18:42:13 +08:00

447 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { useState } from "react";
import {
ModelPicker,
ModelPickerContent,
ModelPickerTrigger,
} from "@/components/workspace/model-picker-content";
import { useAuth } from "@/core/auth/AuthProvider";
import { type Model } from "@/core/models/types";
import { useModelFavorites } from "@/core/models/use-model-favorites";
rs.mock("@/core/auth/AuthProvider", () => ({
useAuth: rs.fn(),
}));
rs.mock("@/core/models/use-model-favorites", () => ({
useModelFavorites: rs.fn(),
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
locale: "en-US",
changeLocale: rs.fn(),
t: {
modelPicker: {
title: "Choose a model",
favorites: "Favorites",
otherModels: "Other models",
noModels: "No models available",
favoriteModel: (displayName: string, name: string) =>
`Favorite ${displayName} (${name})`,
sessionOnly: "Favorites are stored for this session only.",
},
},
}),
}));
const MODELS: readonly Model[] = [
{
id: "one",
name: "provider/alpha",
model: "alpha-api",
display_name: "Shared label",
description: "Fast general model",
},
{
id: "two",
name: ' provider/"beta" ',
model: "beta-api",
display_name: "Shared label",
description: "Careful reasoning model",
},
{
id: "three",
name: "provider/gamma",
model: "gamma-api",
display_name: "Gamma",
description: null,
},
];
const mockedUseAuth = rs.mocked(useAuth);
const mockedUseModelFavorites = rs.mocked(useModelFavorites);
const setFavorite = rs.fn();
let authUser: { id: string } | null;
let authLoading: boolean;
let favoriteNames: readonly string[];
let persistence: "local" | "memory";
function installHookState() {
mockedUseAuth.mockImplementation(
() =>
({
user: authUser,
isAuthenticated: authUser !== null,
isLoading: authLoading,
logout: rs.fn(),
refreshUser: rs.fn(),
applyUser: rs.fn(),
}) as ReturnType<typeof useAuth>,
);
mockedUseModelFavorites.mockImplementation((userId) => ({
names: userId === null ? [] : favoriteNames,
persistence: userId === null ? "memory" : persistence,
canEdit: userId !== null,
setFavorite,
}));
}
interface PickerHarnessProps {
models?: readonly Model[];
selectedModelName?: string;
onModelSelect?: (name: string) => void;
initiallyOpen?: boolean;
}
function StatefulPicker({
models = MODELS,
selectedModelName = MODELS[0]?.name,
onModelSelect = () => undefined,
initiallyOpen = true,
}: PickerHarnessProps) {
const [open, setOpen] = useState(initiallyOpen);
return (
<ModelPicker open={open} onOpenChange={setOpen}>
<ModelPickerTrigger asChild>
<button type="button">Current model</button>
</ModelPickerTrigger>
<ModelPickerContent
open={open}
models={models}
selectedModelName={selectedModelName}
onModelSelect={onModelSelect}
/>
</ModelPicker>
);
}
function ControlledPicker({
open,
models = MODELS,
selectedModelName = MODELS[0]?.name,
onModelSelect = () => undefined,
}: PickerHarnessProps & { open: boolean }) {
return (
<ModelPicker open={open}>
<ModelPickerTrigger asChild>
<button type="button">Current model</button>
</ModelPickerTrigger>
<ModelPickerContent
open={open}
models={models}
selectedModelName={selectedModelName}
onModelSelect={onModelSelect}
/>
</ModelPicker>
);
}
function modelButtons() {
return Array.from(
document.querySelectorAll<HTMLButtonElement>(
'button[data-model-picker-option="true"]',
),
);
}
function favoriteButton(model: Model) {
return screen.getByRole("button", {
name: `Favorite ${model.display_name} (${model.name})`,
});
}
beforeEach(() => {
authUser = { id: "alice" };
authLoading = false;
favoriteNames = [MODELS[1]!.name];
persistence = "local";
setFavorite.mockReset();
installHookState();
});
afterEach(() => {
cleanup();
rs.restoreAllMocks();
});
describe("ModelPickerContent anchored selection", () => {
it("opens without a modal overlay and exposes inline favorite actions", async () => {
render(<StatefulPicker />);
const dialog = await screen.findByRole("dialog", {
name: "Choose a model",
});
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
expect(dialog.className).toContain("w-72");
expect(screen.queryByRole("searchbox")).toBeNull();
expect(screen.queryByText("Fast general model")).toBeNull();
expect(favoriteButton(MODELS[0]!)).not.toBeNull();
expect(
screen.queryByRole("button", { name: "Manage favorites" }),
).toBeNull();
expect(screen.queryByRole("button", { name: "Done" })).toBeNull();
});
it("renders favorites first and preserves API order inside each group", async () => {
render(<StatefulPicker />);
const favorites = await screen.findByRole("group", { name: "Favorites" });
const others = screen.getByRole("group", { name: "Other models" });
expect(
within(favorites).getByText("beta-api").closest("li"),
).not.toBeNull();
expect(
within(others)
.getAllByRole("listitem")
.map((row) => row.textContent),
).toEqual([
expect.stringContaining("alpha-api"),
expect.stringContaining("gamma-api"),
]);
});
it("omits the empty favorites heading without filtering the model list", async () => {
favoriteNames = [];
render(<StatefulPicker />);
await screen.findByRole("dialog");
expect(screen.queryByText("Favorites")).toBeNull();
expect(screen.getByText("Other models")).not.toBeNull();
expect(modelButtons()).toHaveLength(MODELS.length);
});
it("shows the no-model state", async () => {
render(<ControlledPicker open models={[]} />);
expect(await screen.findByText("No models available")).not.toBeNull();
});
it("selects duplicate-label models by their untouched names", async () => {
const onModelSelect = rs.fn();
render(<StatefulPicker onModelSelect={onModelSelect} />);
await screen.findByRole("dialog");
const shared = modelButtons().filter((button) =>
button.textContent?.includes("Shared label"),
);
fireEvent.click(shared[0]!);
fireEvent.click(shared[1]!);
expect(onModelSelect.mock.calls).toEqual([
[MODELS[1]!.name],
[MODELS[0]!.name],
]);
});
it("marks the current model without nesting the favorite button", async () => {
render(<StatefulPicker selectedModelName={MODELS[1]!.name} />);
const current = await screen.findByRole("button", {
name: `Shared label (${MODELS[1]!.name})`,
});
expect(current.getAttribute("aria-current")).toBe("true");
expect(current.getAttribute("data-current-model")).toBe("true");
expect(within(current).queryByRole("button")).toBeNull();
expect(current.parentElement?.contains(favoriteButton(MODELS[1]!))).toBe(
true,
);
});
it("focuses the current model and moves between rows with arrows", async () => {
render(<StatefulPicker />);
const current = await screen.findByRole("button", {
name: `Shared label (${MODELS[0]!.name})`,
});
const gamma = screen.getByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
});
await waitFor(() => expect(document.activeElement).toBe(current));
fireEvent.keyDown(current, { key: "ArrowDown" });
expect(document.activeElement).toBe(gamma);
fireEvent.keyDown(gamma, { key: "ArrowUp" });
expect(document.activeElement).toBe(current);
});
it("moves from favorite stars to adjacent model rows with arrows", async () => {
render(<StatefulPicker />);
const betaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[1]!.display_name} (${MODELS[1]!.name})`,
});
const alpha = screen.getByRole("button", {
name: `Shared label (${MODELS[0]!.name})`,
});
const gamma = screen.getByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
});
betaStar.focus();
fireEvent.keyDown(betaStar, { key: "ArrowDown" });
expect(document.activeElement).toBe(alpha);
betaStar.focus();
fireEvent.keyDown(betaStar, { key: "ArrowUp" });
expect(document.activeElement).toBe(gamma);
});
});
describe("ModelPickerContent favorite actions", () => {
it("updates a favorite without selecting a model or closing the picker", async () => {
const onModelSelect = rs.fn();
render(<StatefulPicker onModelSelect={onModelSelect} />);
const betaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[1]!.display_name} (${MODELS[1]!.name})`,
});
expect(betaStar.getAttribute("aria-pressed")).toBe("true");
expect(betaStar.className).toContain("size-8");
fireEvent.click(betaStar);
expect(setFavorite).toHaveBeenCalledWith(MODELS[1]!.name, false);
expect(onModelSelect).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).not.toBeNull();
});
it("restores focus to the same star after favorite regrouping", async () => {
favoriteNames = [];
const { rerender } = render(<ControlledPicker open />);
const betaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[1]!.display_name} (${MODELS[1]!.name})`,
});
betaStar.focus();
fireEvent.click(betaStar);
favoriteNames = [MODELS[1]!.name];
rerender(<ControlledPicker open />);
await waitFor(() =>
expect(document.activeElement).toBe(favoriteButton(MODELS[1]!)),
);
});
it("preserves the user's focus during an external favorite regroup", async () => {
favoriteNames = [];
const { rerender } = render(
<ControlledPicker open selectedModelName={MODELS[0]!.name} />,
);
const beta = await screen.findByRole("button", {
name: `Shared label (${MODELS[1]!.name})`,
});
beta.focus();
expect(document.activeElement).toBe(beta);
favoriteNames = [MODELS[1]!.name];
rerender(<ControlledPicker open selectedModelName={MODELS[0]!.name} />);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", {
name: `Shared label (${MODELS[1]!.name})`,
}),
),
);
favoriteButton(MODELS[1]!).focus();
favoriteNames = [];
rerender(<ControlledPicker open selectedModelName={MODELS[0]!.name} />);
await waitFor(() =>
expect(document.activeElement).toBe(favoriteButton(MODELS[1]!)),
);
});
it("hides stars when signed out and disables them during auth refresh", async () => {
authUser = null;
const { rerender } = render(<ControlledPicker open />);
await screen.findByRole("dialog");
expect(
screen.queryByRole("button", { name: /Favorite Shared label/ }),
).toBeNull();
authUser = { id: "alice" };
authLoading = true;
rerender(<ControlledPicker open />);
expect(favoriteButton(MODELS[0]!).hasAttribute("disabled")).toBe(true);
});
it("revalidates loading and a same-reference catalog in the latest handler", async () => {
const mutableModels = [...MODELS];
const { rerender } = render(
<ControlledPicker open models={mutableModels} />,
);
const alphaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[0]!.display_name} (${MODELS[0]!.name})`,
});
authLoading = true;
rerender(<ControlledPicker open models={mutableModels} />);
alphaStar.removeAttribute("disabled");
fireEvent.click(alphaStar);
expect(setFavorite).not.toHaveBeenCalled();
authLoading = false;
rerender(<ControlledPicker open models={mutableModels} />);
const connectedStar = favoriteButton(MODELS[0]!);
mutableModels.splice(0, 1);
fireEvent.click(connectedStar);
expect(setFavorite).not.toHaveBeenCalled();
});
it("only announces persistence when storage falls back to memory", async () => {
const { rerender } = render(<ControlledPicker open />);
await screen.findByRole("dialog");
expect(screen.queryByRole("status")).toBeNull();
persistence = "memory";
rerender(<ControlledPicker open />);
expect(screen.getByRole("status").textContent).toBe(
"Favorites are stored for this session only.",
);
});
});
describe("ModelPickerContent popover lifecycle", () => {
it("focuses the current model on every closed-to-open edge", async () => {
const { rerender } = render(
<ControlledPicker open selectedModelName={MODELS[2]!.name} />,
);
const current = await screen.findByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
});
await waitFor(() => expect(document.activeElement).toBe(current));
rerender(
<ControlledPicker open={false} selectedModelName={MODELS[2]!.name} />,
);
screen.getByRole("button", { name: "Current model" }).focus();
rerender(<ControlledPicker open selectedModelName={MODELS[2]!.name} />);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
}),
),
);
});
it("closes on Escape and returns focus to the trigger", async () => {
render(<StatefulPicker />);
await screen.findByRole("dialog");
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Current model" }),
);
});
});