mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
305 lines
9.5 KiB
TypeScript
305 lines
9.5 KiB
TypeScript
import { createElement, type ReactNode } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { act, renderHook } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { api } from "@multica/core/api";
|
|
import type { CommentTriggerPreviewAgent } from "@multica/core/types";
|
|
import {
|
|
commentTriggerPreviewSignature,
|
|
isNoteCommentDraft,
|
|
useCommentTriggerPreview,
|
|
} from "./use-comment-trigger-preview";
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: {
|
|
previewCommentTriggers: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const previewCommentTriggers = vi.mocked(api.previewCommentTriggers);
|
|
const waltAgent: CommentTriggerPreviewAgent = {
|
|
id: "00000000-0000-0000-0000-000000000001",
|
|
name: "Walt",
|
|
source: "issue_assignee",
|
|
reason: "",
|
|
};
|
|
const kimAgent: CommentTriggerPreviewAgent = {
|
|
id: "00000000-0000-0000-0000-000000000002",
|
|
name: "Kim",
|
|
source: "mention_agent",
|
|
reason: "",
|
|
};
|
|
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
|
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
return createElement(QueryClientProvider, { client: queryClient }, children);
|
|
};
|
|
}
|
|
|
|
async function advancePreviewDebounce() {
|
|
act(() => {
|
|
vi.advanceTimersByTime(300);
|
|
});
|
|
await act(async () => {});
|
|
}
|
|
|
|
describe("useCommentTriggerPreview", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
previewCommentTriggers.mockResolvedValue({ agents: [] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
previewCommentTriggers.mockReset();
|
|
});
|
|
|
|
it("debounces preview and sends the latest content for an unchanged signature", async () => {
|
|
const { rerender } = renderHook(
|
|
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
|
|
{
|
|
wrapper: createWrapper(),
|
|
initialProps: { content: "hello" },
|
|
},
|
|
);
|
|
|
|
rerender({ content: "hello with more ordinary text" });
|
|
expect(previewCommentTriggers).not.toHaveBeenCalled();
|
|
|
|
await advancePreviewDebounce();
|
|
|
|
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
|
|
expect(previewCommentTriggers).toHaveBeenCalledWith(
|
|
"issue-1",
|
|
"hello with more ordinary text",
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("passes editing comment context and refetches when it changes", async () => {
|
|
const agentA = "00000000-0000-0000-0000-000000000001";
|
|
const content = `[@A](mention://agent/${agentA})`;
|
|
const { rerender } = renderHook(
|
|
({ editingCommentId }) =>
|
|
useCommentTriggerPreview({
|
|
issueId: "issue-1",
|
|
parentId: "parent-1",
|
|
editingCommentId,
|
|
content,
|
|
}),
|
|
{
|
|
wrapper: createWrapper(),
|
|
initialProps: { editingCommentId: "comment-1" },
|
|
},
|
|
);
|
|
|
|
await advancePreviewDebounce();
|
|
expect(previewCommentTriggers).toHaveBeenCalledWith(
|
|
"issue-1",
|
|
content,
|
|
"parent-1",
|
|
"comment-1",
|
|
);
|
|
|
|
rerender({ editingCommentId: "comment-2" });
|
|
await advancePreviewDebounce();
|
|
|
|
expect(previewCommentTriggers).toHaveBeenCalledTimes(2);
|
|
expect(previewCommentTriggers).toHaveBeenLastCalledWith(
|
|
"issue-1",
|
|
content,
|
|
"parent-1",
|
|
"comment-2",
|
|
);
|
|
});
|
|
|
|
it("does not show previous agents while parent context changes", async () => {
|
|
previewCommentTriggers
|
|
.mockResolvedValueOnce({ agents: [waltAgent] })
|
|
.mockReturnValueOnce(new Promise(() => {}));
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ parentId }) =>
|
|
useCommentTriggerPreview({
|
|
issueId: "issue-1",
|
|
parentId,
|
|
content: "plain reply",
|
|
}),
|
|
{
|
|
wrapper: createWrapper(),
|
|
initialProps: { parentId: "walt-thread" },
|
|
},
|
|
);
|
|
|
|
await advancePreviewDebounce();
|
|
await vi.waitFor(() => {
|
|
expect(result.current.agents).toEqual([waltAgent]);
|
|
});
|
|
|
|
rerender({ parentId: "kim-thread" });
|
|
await act(async () => {});
|
|
|
|
expect(result.current.agents).toEqual([]);
|
|
expect(previewCommentTriggers).toHaveBeenLastCalledWith(
|
|
"issue-1",
|
|
"plain reply",
|
|
"kim-thread",
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("keeps previous agents while the same context refetches", async () => {
|
|
previewCommentTriggers
|
|
.mockResolvedValueOnce({ agents: [waltAgent] })
|
|
.mockReturnValueOnce(new Promise(() => {}));
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ content }) =>
|
|
useCommentTriggerPreview({
|
|
issueId: "issue-1",
|
|
parentId: "same-thread",
|
|
content,
|
|
}),
|
|
{
|
|
wrapper: createWrapper(),
|
|
initialProps: { content: `[@Walt](mention://agent/${waltAgent.id})` },
|
|
},
|
|
);
|
|
|
|
await advancePreviewDebounce();
|
|
await vi.waitFor(() => {
|
|
expect(result.current.agents).toEqual([waltAgent]);
|
|
});
|
|
|
|
rerender({ content: `[@Kim](mention://agent/${kimAgent.id})` });
|
|
await advancePreviewDebounce();
|
|
|
|
expect(result.current.agents).toEqual([waltAgent]);
|
|
expect(previewCommentTriggers).toHaveBeenLastCalledWith(
|
|
"issue-1",
|
|
`[@Kim](mention://agent/${kimAgent.id})`,
|
|
"same-thread",
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("revalidates when the debounced signature repeats — the answer is queue-state dependent", async () => {
|
|
const agentA = "00000000-0000-0000-0000-000000000001";
|
|
const content = `[@A](mention://agent/${agentA})`;
|
|
const agents = [
|
|
{ id: agentA, name: "A", source: "mention_agent", reason: "" },
|
|
];
|
|
previewCommentTriggers.mockResolvedValue({ agents });
|
|
const { result, rerender } = renderHook(
|
|
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
|
|
{
|
|
wrapper: createWrapper(),
|
|
initialProps: { content },
|
|
},
|
|
);
|
|
|
|
await advancePreviewDebounce();
|
|
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
|
|
|
|
rerender({ content: "" });
|
|
rerender({ content });
|
|
// Cached agents render immediately for the repeated signature (no
|
|
// flicker)…
|
|
await advancePreviewDebounce();
|
|
expect(result.current.agents).toEqual(agents);
|
|
// …but a background revalidation still fires: an agent finishing its
|
|
// queued task changes the answer for the very same mention set.
|
|
expect(previewCommentTriggers).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("fetches again when routing mention tokens change", async () => {
|
|
const agentA = "00000000-0000-0000-0000-000000000001";
|
|
const agentB = "00000000-0000-0000-0000-000000000002";
|
|
const { rerender } = renderHook(
|
|
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
|
|
{
|
|
wrapper: createWrapper(),
|
|
initialProps: { content: `[@A](mention://agent/${agentA})` },
|
|
},
|
|
);
|
|
|
|
await advancePreviewDebounce();
|
|
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
|
|
|
|
rerender({ content: `[@A](mention://agent/${agentA}) [@B](mention://agent/${agentB})` });
|
|
await advancePreviewDebounce();
|
|
|
|
expect(previewCommentTriggers).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not preview note drafts", async () => {
|
|
const agentA = "00000000-0000-0000-0000-000000000001";
|
|
const { result } = renderHook(
|
|
() => useCommentTriggerPreview({
|
|
issueId: "issue-1",
|
|
content: `/note [@A](mention://agent/${agentA})`,
|
|
}),
|
|
{ wrapper: createWrapper() },
|
|
);
|
|
|
|
await advancePreviewDebounce();
|
|
|
|
expect(result.current).toEqual({ agents: [] });
|
|
expect(previewCommentTriggers).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("commentTriggerPreviewSignature", () => {
|
|
it("ignores ordinary text changes", () => {
|
|
expect(commentTriggerPreviewSignature("hello")).toBe(
|
|
commentTriggerPreviewSignature("hello with more ordinary text"),
|
|
);
|
|
});
|
|
|
|
it("changes when routing mentions change", () => {
|
|
const agentA = "00000000-0000-0000-0000-000000000001";
|
|
const agentB = "00000000-0000-0000-0000-000000000002";
|
|
|
|
expect(commentTriggerPreviewSignature(`[@A](mention://agent/${agentA})`)).not.toBe(
|
|
commentTriggerPreviewSignature(`[@A](mention://agent/${agentA}) [@B](mention://agent/${agentB})`),
|
|
);
|
|
});
|
|
|
|
it("tracks @all but ignores issue cross-references", () => {
|
|
const issueID = "00000000-0000-0000-0000-000000000003";
|
|
|
|
expect(commentTriggerPreviewSignature(`See [MUL-1](mention://issue/${issueID})`)).toBe(
|
|
commentTriggerPreviewSignature("plain text"),
|
|
);
|
|
expect(commentTriggerPreviewSignature("[@all](mention://all/all)")).not.toBe(
|
|
commentTriggerPreviewSignature("plain text"),
|
|
);
|
|
});
|
|
|
|
it("treats note commands as empty", () => {
|
|
const agentA = "00000000-0000-0000-0000-000000000001";
|
|
|
|
expect(commentTriggerPreviewSignature(`/note [@A](mention://agent/${agentA})`)).toBe("empty");
|
|
expect(commentTriggerPreviewSignature(` /NOTE\n[@A](mention://agent/${agentA})`)).toBe("empty");
|
|
expect(commentTriggerPreviewSignature(`/notes [@A](mention://agent/${agentA})`)).not.toBe("empty");
|
|
expect(commentTriggerPreviewSignature(`/ note [@A](mention://agent/${agentA})`)).not.toBe("empty");
|
|
});
|
|
});
|
|
|
|
describe("isNoteCommentDraft", () => {
|
|
it("matches the reserved note prefix only as the first token", () => {
|
|
expect(isNoteCommentDraft("/note")).toBe(true);
|
|
expect(isNoteCommentDraft(" \t/Note keep this human-only")).toBe(true);
|
|
expect(isNoteCommentDraft("/notes keep this routable")).toBe(false);
|
|
expect(isNoteCommentDraft("/ note keep this routable")).toBe(false);
|
|
expect(isNoteCommentDraft("please /note later")).toBe(false);
|
|
});
|
|
});
|