Files
fusion/packages/dashboard/app/hooks/__tests__/useFileMention.test.ts
Fusion (runfusion.ai) 12a6ee9af1 feat(FN-5218): add hash mentions to chat composers and popups
Merges FN-5218: adds hash mention support (`#`) in chat composers, grouping task and file reference results in a unified popup, with wiring into `ChatView` and `QuickChatFAB`, plus test coverage and docs. The hook `useFileMention` is extended and refactored, and the file mention popup UI is updated

Fusion-Task-Id: FN-5218
2026-05-20 04:12:40 -07:00

277 lines
9.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useFileMention } from "../useFileMention";
vi.mock("../../api", () => ({
fetchTasks: vi.fn(),
searchFiles: vi.fn(),
}));
import { fetchTasks, searchFiles } from "../../api";
const mockFetchTasks = vi.mocked(fetchTasks);
const mockSearchFiles = vi.mocked(searchFiles);
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flushSearch() {
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
await Promise.resolve();
});
}
describe("useFileMention", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mockFetchTasks.mockResolvedValue([]);
mockSearchFiles.mockResolvedValue({ files: [] });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("detectMention", () => {
it("returns false when cursor is at start with no text", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("", 0);
});
expect(result.current.mentionActive).toBe(false);
});
it("detects # at start of text", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#", 1);
});
expect(result.current.mentionActive).toBe(true);
expect(result.current.mentionQuery).toBe("");
});
it("detects # after whitespace with partial filename", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("Hello #sr", 9);
});
expect(result.current.mentionActive).toBe(true);
expect(result.current.mentionQuery).toBe("sr");
});
it("ignores # after non-whitespace", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("foo#bar", 6);
});
expect(result.current.mentionActive).toBe(false);
});
it("resets selected index when detection changes", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.setSelectedIndex(3);
result.current.detectMention("#src", 4);
});
expect(result.current.selectedIndex).toBe(0);
});
});
describe("selection helpers", () => {
it("replaces partial mention with full file path", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("Check #src/ind", 14);
});
expect(
result.current.selectFile({ path: "src/index.ts", name: "index.ts" }, "Check #src/ind"),
).toBe("Check #src/index.ts");
});
it("replaces partial mention with a task id", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("Check #FN-5 now", 11);
});
expect(
result.current.selectTask({ id: "FN-5218", title: "Hash mentions", column: "todo" }, "Check #FN-5 now"),
).toBe("Check #FN-5218 now");
});
it("does nothing when mention is not active", () => {
const { result } = renderHook(() => useFileMention());
expect(result.current.selectFile({ path: "src/index.ts", name: "index.ts" }, "Some text")).toBe("Some text");
});
});
describe("search", () => {
it("returns task matches for id queries", async () => {
mockFetchTasks.mockResolvedValue([
{ id: "FN-5218", title: "Hash entries in chat", column: "todo" } as never,
]);
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#FN-", 4);
});
await flushSearch();
expect(result.current.tasks).toEqual([
{ id: "FN-5218", title: "Hash entries in chat", column: "todo" },
]);
expect(result.current.combinedItems[0]).toEqual({
kind: "task",
task: { id: "FN-5218", title: "Hash entries in chat", column: "todo" },
});
});
it("returns file matches for substring queries", async () => {
mockSearchFiles.mockResolvedValue({
files: [{ path: "src/project.ts", name: "project.ts" }],
});
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#proj", 5);
});
await flushSearch();
expect(result.current.files).toEqual([{ path: "src/project.ts", name: "project.ts" }]);
});
it("keeps file results when task search rejects", async () => {
mockFetchTasks.mockRejectedValue(new Error("task search failed"));
mockSearchFiles.mockResolvedValue({
files: [{ path: "src/project.ts", name: "project.ts" }],
});
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#proj", 5);
});
await flushSearch();
expect(result.current.files).toHaveLength(1);
expect(result.current.tasks).toEqual([]);
});
it("keeps task results when file search rejects", async () => {
mockFetchTasks.mockResolvedValue([
{ id: "FN-5218", title: "Hash entries in chat", column: "todo" } as never,
]);
mockSearchFiles.mockRejectedValue(new Error("file search failed"));
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#FN", 3);
});
await flushSearch();
expect(result.current.tasks).toHaveLength(1);
expect(result.current.files).toEqual([]);
});
it("cancels stale rapid-query results", async () => {
const firstTasks = deferred<Array<{ id: string; title: string; column: string }>>();
const secondTasks = deferred<Array<{ id: string; title: string; column: string }>>();
const firstFiles = deferred<{ files: Array<{ path: string; name: string }> }>();
const secondFiles = deferred<{ files: Array<{ path: string; name: string }> }>();
mockFetchTasks.mockReturnValueOnce(firstTasks.promise as never).mockReturnValueOnce(secondTasks.promise as never);
mockSearchFiles.mockReturnValueOnce(firstFiles.promise).mockReturnValueOnce(secondFiles.promise);
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#FN", 3);
});
await flushSearch();
act(() => {
result.current.detectMention("#FN-5", 5);
});
await flushSearch();
await act(async () => {
firstTasks.resolve([{ id: "FN-1111", title: "Old", column: "todo" }]);
firstFiles.resolve({ files: [{ path: "old.ts", name: "old.ts" }] });
secondTasks.resolve([{ id: "FN-5218", title: "New", column: "done" }]);
secondFiles.resolve({ files: [{ path: "new.ts", name: "new.ts" }] });
await Promise.resolve();
});
expect(result.current.tasks).toEqual([{ id: "FN-5218", title: "New", column: "done" }]);
expect(result.current.files).toEqual([{ path: "new.ts", name: "new.ts" }]);
});
});
describe("handleKeyDown", () => {
it("returns false when mention is not active", () => {
const { result } = renderHook(() => useFileMention());
const handled = result.current.handleKeyDown(
{ key: "ArrowDown", preventDefault: vi.fn() } as unknown as React.KeyboardEvent<HTMLElement>,
"text",
);
expect(handled).toBe(false);
});
it("walks the combined list with tasks first", async () => {
mockFetchTasks.mockResolvedValue([
{ id: "FN-5218", title: "Task one", column: "todo" } as never,
{ id: "FN-5219", title: "Task two", column: "in-progress" } as never,
]);
mockSearchFiles.mockResolvedValue({
files: [{ path: "src/project.ts", name: "project.ts" }],
});
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#proj", 5);
});
await flushSearch();
expect(result.current.combinedItems).toHaveLength(3);
const preventDefault = vi.fn();
act(() => {
result.current.handleKeyDown(
{ key: "ArrowDown", preventDefault } as unknown as React.KeyboardEvent<HTMLElement>,
"#proj",
);
result.current.handleKeyDown(
{ key: "ArrowDown", preventDefault } as unknown as React.KeyboardEvent<HTMLElement>,
"#proj",
);
});
expect(result.current.selectedIndex).toBe(2);
expect(result.current.combinedItems[2]).toEqual({
kind: "file",
file: { path: "src/project.ts", name: "project.ts" },
});
});
it("returns false when no results are loaded", () => {
const { result } = renderHook(() => useFileMention());
act(() => {
result.current.detectMention("#test", 5);
});
const handled = result.current.handleKeyDown(
{ key: "Enter", preventDefault: vi.fn() } as unknown as React.KeyboardEvent<HTMLElement>,
"#test",
);
expect(handled).toBe(false);
});
});
});