test(frontend): cover NewSessionDialog model degradation + invalidate sessions on resume
- Add NewSessionDialog.test.tsx covering §9 graceful degradation:
empty models -> free-text input; non-empty models -> dropdown; plus a
createSession body-keys assertion
- Normalize model entries to {value,label} so object-shaped models
({provider,id}) render correctly in the dropdown (was assuming strings)
- NavSessions: invalidate the ["sessions"] query after a successful
resumeSession so the list/status refreshes immediately
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// frontend/src/shell/NavSessions.tsx
|
// frontend/src/shell/NavSessions.tsx
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { listSessions } from "../api/sessions";
|
import { listSessions } from "../api/sessions";
|
||||||
import { resumeSession } from "../api/sessions";
|
import { resumeSession } from "../api/sessions";
|
||||||
import type { SessionSummary } from "../api/types";
|
import type { SessionSummary } from "../api/types";
|
||||||
@@ -10,6 +10,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||||||
queryKey: ["sessions"],
|
queryKey: ["sessions"],
|
||||||
queryFn: listSessions,
|
queryFn: listSessions,
|
||||||
@@ -18,6 +19,7 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
|||||||
|
|
||||||
async function handleClick(id: string) {
|
async function handleClick(id: string) {
|
||||||
await resumeSession(id);
|
await resumeSession(id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||||
onSessionSelect(id);
|
onSessionSelect(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// frontend/src/shell/NewSessionDialog.test.tsx
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { server } from "../test/msw";
|
||||||
|
import { NewSessionDialog } from "./NewSessionDialog";
|
||||||
|
|
||||||
|
function renderDialog() {
|
||||||
|
const onCreated = vi.fn();
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<NewSessionDialog onCreated={onCreated} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return { onCreated };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/workspaces", () =>
|
||||||
|
HttpResponse.json([{ name: "default", file: "default.db" }]),
|
||||||
|
),
|
||||||
|
http.post("http://localhost:8787/sessions", () =>
|
||||||
|
HttpResponse.json({ id: "s1" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("§9 degradation: empty models -> model field is a FREE-TEXT input, not a select", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
||||||
|
);
|
||||||
|
renderDialog();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||||||
|
|
||||||
|
// Free-text model input present; no model dropdown.
|
||||||
|
const freetext = await screen.findByTestId("model-freetext");
|
||||||
|
expect(freetext.tagName).toBe("INPUT");
|
||||||
|
// The only <select> in the dialog is the workspace one (id nsd-workspace).
|
||||||
|
expect(screen.queryByRole("combobox", { name: /modello/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("§9: non-empty models -> model field is a dropdown (select), not free text", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/models", () =>
|
||||||
|
HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2" }] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderDialog();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||||||
|
|
||||||
|
// Model dropdown present; no free-text input.
|
||||||
|
const modelSelect = await screen.findByRole("combobox", { name: /modello/i });
|
||||||
|
expect(modelSelect.tagName).toBe("SELECT");
|
||||||
|
expect(screen.queryByTestId("model-freetext")).not.toBeInTheDocument();
|
||||||
|
// Option label combines provider/id.
|
||||||
|
expect(screen.getByRole("option", { name: "zai/glm-5.2" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submitting calls createSession with the expected body keys", async () => {
|
||||||
|
let body: unknown = null;
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
||||||
|
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "s1" });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { onCreated } = renderDialog();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||||||
|
|
||||||
|
const textarea = await screen.findByLabelText(/domanda/i);
|
||||||
|
await userEvent.type(textarea, "Quante vendite nel 2025?");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /^crea$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(body).toEqual({ workspace: "default", question: "Quante vendite nel 2025?" });
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
||||||
|
});
|
||||||
@@ -38,8 +38,18 @@ export function NewSessionDialog({ onCreated }: Props) {
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
const modelList: string[] = Array.isArray(modelsData?.models)
|
// Model entries may be plain strings or objects ({ id, provider, ... });
|
||||||
? (modelsData.models as string[])
|
// normalize to { value, label } so the dropdown renders either shape.
|
||||||
|
const modelList: { value: string; label: string }[] = Array.isArray(
|
||||||
|
modelsData?.models,
|
||||||
|
)
|
||||||
|
? modelsData.models.map((m) => {
|
||||||
|
if (typeof m === "string") return { value: m, label: m };
|
||||||
|
const o = m as { id?: string; provider?: string };
|
||||||
|
const value = o.id ?? "";
|
||||||
|
const label = o.provider ? `${o.provider}/${value}` : value;
|
||||||
|
return { value, label };
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// When workspace list loads, pre-select the first one if nothing chosen yet
|
// When workspace list loads, pre-select the first one if nothing chosen yet
|
||||||
@@ -140,8 +150,8 @@ export function NewSessionDialog({ onCreated }: Props) {
|
|||||||
>
|
>
|
||||||
<option value="">— default —</option>
|
<option value="">— default —</option>
|
||||||
{modelList.map((m) => (
|
{modelList.map((m) => (
|
||||||
<option key={m} value={m}>
|
<option key={m.value} value={m.value}>
|
||||||
{m}
|
{m.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Reference in New Issue
Block a user