Files
multica/packages/views/issues/hooks/use-comment-trigger-preview.test.ts
Naiyuan Qing 1afa493165 fix(comments): align trigger preview context (#4147)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 08:20:15 +08:00

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);
});
});