lib/assistant-chat-context.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AssistantChatContextValue } from "./assistant-chat-context";
import type { OpenAssistantOptions } from "./assistant-panel-context";
const h = vi.hoisted(() => ({
panel: { isOpen: true, pendingOptions: null as OpenAssistantOptions | null, routeProjectId: "a" as string | null, close: vi.fn() },
state: {
conversationId: "conversation",
conversationProjectId: "a",
messages: [{ role: "user", content: "Earlier message" }],
status: "idle",
completedResponse: null as { conversationId: string; messageId: string } | null,
routineOccurrence: null as {
id: string;
routine_id: string;
origin: "scheduled" | "manual";
scheduled_for: string | null;
created_at: string;
} | null,
},
read: vi.fn(), load: vi.fn(), reset: vi.fn(), send: vi.fn(), abort: vi.fn(), active: vi.fn(), pointer: vi.fn(),
chatOptions: null as { onToolResult?: (name: string, success: boolean, result: unknown) => void } | null,
router: { push: vi.fn(), refresh: vi.fn() },
theme: { setTheme: vi.fn() }, auth: { refreshUser: vi.fn() },
}));
vi.mock("./assistant-panel-context", () => ({ useAssistantPanel: () => h.panel }));
vi.mock("./use-assistant-chat", () => ({ useAssistantChat: (options: { onToolResult?: (name: string, success: boolean, result: unknown) => void }) => { h.chatOptions = options; return { state: h.state, loadConversation: h.load, reset: h.reset, sendMessage: h.send, abort: h.abort }; } }));
vi.mock("./assistant-api", () => ({ fetchActiveConversation: h.active, setActiveConversation: h.pointer, updateConversation: h.read }));
vi.mock("./auth-context", () => ({ useAuth: () => h.auth }));
vi.mock("next-intl", () => ({ useLocale: () => "en" }));
vi.mock("next/navigation", () => ({ useRouter: () => h.router }));
vi.mock("@/components/theme-provider", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTheme: () => h.theme,
}));
vi.mock("./set-locale", () => ({ setLocaleCookie: async () => {} }));
import { AssistantChatProvider, useAssistantChatContext, useAssistantUnreadResponseConversationId } from "./assistant-chat-context";
let root: Root;
let value: AssistantChatContextValue;
let unread: boolean;
let unreadConversationId: string | null;
function Probe() { value = useAssistantChatContext(); unreadConversationId = useAssistantUnreadResponseConversationId(); unread = !!unreadConversationId; return null; }
async function render(show = true) {
await act(async () => root.render(createElement(AssistantChatProvider, { children: show ? createElement(Probe) : null })));
}
beforeEach(() => {
vi.clearAllMocks();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
h.panel = { ...h.panel, isOpen: true, pendingOptions: null, routeProjectId: "a" };
h.state = { ...h.state, status: "idle", routineOccurrence: null, completedResponse: null };
h.read.mockResolvedValue(true);
h.active.mockResolvedValue({ conversationId: "conversation", projectId: "a" });
root = createRoot(document.createElement("div"));
});
afterEach(() => { act(() => root.unmount()); });
describe("persistent conversation context", () => {
it("keeps a background answer unread across navigation and opening until displayed", async () => {
h.panel.isOpen = false;
await render();
expect(unread).toBe(false);
h.state = { ...h.state, completedResponse: { conversationId: "conversation", messageId: "answer" } };
await render();
expect(unread).toBe(true);
h.state = { ...h.state, completedResponse: null };
h.panel = { ...h.panel, routeProjectId: "b", isOpen: true };
await render();
expect(unread).toBe(true);
await act(async () => value.markResponseRead("other-conversation"));
expect(unread).toBe(true);
expect(h.read).not.toHaveBeenCalled();
await act(async () => value.markResponseRead("conversation"));
expect(unread).toBe(false);
expect(h.read).toHaveBeenCalledWith("conversation", { read: true });
h.panel.isOpen = false;
await render();
expect(unread).toBe(false);
h.state = { ...h.state, completedResponse: { conversationId: "conversation", messageId: "next-answer" } };
await render();
expect(unread).toBe(true);
});
it("retains unread responses when another conversation is loaded or completed", async () => {
h.panel.isOpen = false;
h.state = { ...h.state, completedResponse: { conversationId: "first", messageId: "answer-1" } };
await render();
h.state = { ...h.state, completedResponse: null };
await render();
expect(unread).toBe(true);
await act(async () => value.markResponseRead("other"));
expect(unread).toBe(true);
h.state = { ...h.state, completedResponse: { conversationId: "second", messageId: "answer-2" } };
await render();
await act(async () => value.markResponseRead("second"));
expect(unread).toBe(true);
await act(async () => value.markResponseRead("first"));
expect(unread).toBe(false);
// A repeated completion replay must not resurrect a response already read.
h.state = { ...h.state, completedResponse: { conversationId: "first", messageId: "answer-1" } };
await render();
expect(unread).toBe(false);
});
it("does not notify on busy-to-idle transitions without a completed answer", async () => {
h.panel.isOpen = false;
h.state = { ...h.state, status: "streaming" };
await render();
h.state = { ...h.state, status: "idle" };
await render();
expect(unread).toBe(false);
h.state = { ...h.state, status: "error" };
await render();
expect(unread).toBe(false);
});
it("lets the full page request the same lazy restore while the panel is closed", async () => {
h.panel.isOpen = false;
await render();
expect(h.active).not.toHaveBeenCalled();
await act(async () => value.requestRestore());
expect(h.active).toHaveBeenCalledOnce();
expect(h.load).toHaveBeenCalledWith("conversation", "a");
});
it("keeps a standalone surface (Home) off the panel's active conversation", async () => {
h.panel.isOpen = false;
await act(async () => root.render(createElement(AssistantChatProvider, {
standalone: true,
children: createElement(Probe),
})));
await act(async () => value.requestRestore());
expect(value.restoring).toBe(false);
expect(h.active).not.toHaveBeenCalled();
expect(h.load).not.toHaveBeenCalled();
expect(h.pointer).not.toHaveBeenCalled();
});
it("keeps history and removable pins across navigation, panel unmounts and an active response", async () => {
await render();
await act(async () => value.setPinned([{ kind: "project", id: "a", label: "A" }]));
h.state = { ...h.state, status: "streaming" };
h.panel = { ...h.panel, routeProjectId: "b", pendingOptions: { projectId: "b" } };
await render();
expect(value.scopeProjectId).toBe("b");
expect(value.isBusy).toBe(true);
expect(value.state.conversationId).toBe("conversation");
expect(value.state.messages).toHaveLength(1);
await render(false);
await render();
expect(value.pinned).toEqual([{ kind: "project", id: "a", label: "A" }]);
expect(h.reset).not.toHaveBeenCalled();
expect(h.abort).not.toHaveBeenCalled();
await act(async () => value.setPinned([]));
expect(value.pinned).toEqual([]);
});
it("restores the existing conversation before a contextual opening, even if closed while loading", async () => {
let resolve!: (value: unknown) => void;
h.active.mockReturnValue(new Promise((done) => { resolve = done; }));
h.panel.pendingOptions = { projectId: "b", prompt: "Discuss B" };
await render();
await act(async () => value.requestRestore());
expect(value.restoring).toBe(true);
h.panel = { ...h.panel, isOpen: false };
await render();
await act(async () => resolve({ conversationId: "conversation", projectId: "a" }));
h.panel = { ...h.panel, isOpen: true };
await render();
expect(h.load).toHaveBeenCalledWith("conversation", "a");
expect(value.restoring).toBe(false);
expect(value.scopeProjectId).toBe("b");
expect(h.reset).not.toHaveBeenCalled();
});
it("keeps routine-owned conversations out of the active Numo pointer", async () => {
h.state = {
...h.state,
routineOccurrence: {
id: "occurrence",
routine_id: "routine",
origin: "scheduled",
scheduled_for: "2026-09-13T08:00:00.000Z",
created_at: "2026-09-13T08:00:00.000Z",
},
};
await render();
await act(async () => value.requestRestore());
expect(h.pointer).toHaveBeenLastCalledWith(null);
});
/**
* A delegation launched mid-turn must light the FAB border and the sidebar
* spinner at once: the sessions list rests (no poll) and only an
* invalidation sees a run that just started.
*/
});