Files
fusion/packages/dashboard/app/hooks/__tests__/useTaskRecommendations.test.ts
gsxdsm f7bf3f91d1 FN-9022: add task recommendations to Insights
Expose actionable task recommendations in the Insights view.

- Add recommendation list API, client hook, and Insights rendering states.
- Localize recommendation content and document the Insights workflow.
- Cover recommendation retrieval, routes, hooks, API, and view behavior.

Files changed:
 .changeset/fn-9022-insights-recommendations.md     |   7 ++
 docs/architecture.md                               |   1 +
 docs/dashboard-guide.md                            |   3 +-
 .../__tests__/task-recommendations-list.test.ts    | 136 +++++++++++++++++++++
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/store.ts                         |   5 +-
 packages/core/src/task-store/reads.ts              |  48 +++++++-
 packages/core/src/types.ts                         |   4 +
 packages/core/src/types/task/task-core.ts          |  19 +++
 packages/dashboard/app/__tests__/api-tasks.test.ts |  24 ++++
 packages/dashboard/app/api/legacy.ts               |   3 +
 packages/dashboard/app/api/tasks/tasks.ts          |  22 ++++
 packages/dashboard/app/components/InsightsView.css |  27 ++++
 packages/dashboard/app/components/InsightsView.tsx |  47 +++++--
 .../app/components/__tests__/InsightsView.test.tsx | 117 ++++++++++++++++++
 .../hooks/__tests__/useTaskRecommendations.test.ts | 130 ++++++++++++++++++++
 .../dashboard/app/hooks/useTaskRecommendations.ts  | 116 ++++++++++++++++++
 .../__tests__/task-recommendation-routes.test.ts   |  26 ++++
 .../src/routes/register-task-workflow-routes.ts    |  33 +++++
 packages/i18n/locales/en/app.json                  |  11 +-
 packages/i18n/locales/es/app.json                  |  11 +-
 packages/i18n/locales/fr/app.json                  |  11 +-
 packages/i18n/locales/ko/app.json                  |  11 +-
 packages/i18n/locales/pt-BR/app.json               |  11 +-
 packages/i18n/locales/zh-CN/app.json               |  11 +-
 packages/i18n/locales/zh-TW/app.json               |  11 +-
 packages/i18n/src/resources.d.ts                   |   9 ++
 27 files changed, 835 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-9022

Fusion-Task-Lineage: b83f1074-37af-4f87-9ec0-2bd9bb9bf64d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-12 22:18:18 -07:00

131 lines
6.5 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useTaskRecommendations } from "../useTaskRecommendations";
vi.mock("../../api", () => ({
fetchTaskRecommendations: vi.fn(),
createTaskFromRecommendation: vi.fn(),
}));
import { createTaskFromRecommendation, fetchTaskRecommendations } from "../../api";
const mockFetchTaskRecommendations = vi.mocked(fetchTaskRecommendations);
const mockCreateTaskFromRecommendation = vi.mocked(createTaskFromRecommendation);
const item = (taskId: string, recommendationId: string) => ({
taskId,
taskTitle: `Task ${taskId}`,
recommendation: {
id: recommendationId,
title: `Recommendation ${recommendationId}`,
description: "Follow up",
category: "improvement",
},
});
const page = (items: ReturnType<typeof item>[], options: { rowOffset?: number; returnedRowCount?: number; totalRowCount?: number; hasMore?: boolean } = {}) => ({
items,
rowOffset: options.rowOffset ?? 0,
rowLimit: 50,
returnedRowCount: options.returnedRowCount ?? 1,
totalRowCount: options.totalRowCount ?? 1,
hasMore: options.hasMore ?? false,
});
describe("useTaskRecommendations", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("pages by returned source rows and appends only unseen composite recommendation keys", async () => {
mockFetchTaskRecommendations
.mockResolvedValueOnce(page([item("FN-1", "same")], { returnedRowCount: 1, totalRowCount: 3, hasMore: true }) as never)
.mockResolvedValueOnce(page([item("FN-1", "same"), item("FN-2", "same")], { rowOffset: 1, returnedRowCount: 2, totalRowCount: 3 }) as never);
const { result } = renderHook(() => useTaskRecommendations("project-a"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetchTaskRecommendations).toHaveBeenLastCalledWith("project-a", { limit: 50, offset: 0 });
await act(async () => { await result.current.loadMore(); });
expect(mockFetchTaskRecommendations).toHaveBeenLastCalledWith("project-a", { limit: 50, offset: 1 });
expect(result.current.items.map((entry) => `${entry.taskId}:${entry.recommendation.id}`)).toEqual(["FN-1:same", "FN-2:same"]);
expect(result.current.hasMore).toBe(false);
});
it("retains loaded recommendations and permits retry after a later page fails", async () => {
mockFetchTaskRecommendations
.mockResolvedValueOnce(page([item("FN-1", "REC-1")], { returnedRowCount: 1, totalRowCount: 2, hasMore: true }) as never)
.mockRejectedValueOnce(new Error("network unavailable"))
.mockResolvedValueOnce(page([item("FN-2", "REC-2")], { rowOffset: 1, returnedRowCount: 1, totalRowCount: 2 }) as never);
const { result } = renderHook(() => useTaskRecommendations("project-a"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { await result.current.loadMore(); });
expect(result.current.items).toHaveLength(1);
expect(result.current.error).toBe("network unavailable");
expect(result.current.hasMore).toBe(true);
await act(async () => { await result.current.loadMore(); });
expect(result.current.items.map((entry) => entry.taskId)).toEqual(["FN-1", "FN-2"]);
expect(result.current.error).toBeNull();
});
it("recovers an initial fetch failure through refresh without losing the retry path", async () => {
mockFetchTaskRecommendations
.mockRejectedValueOnce(new Error("initial unavailable"))
.mockResolvedValueOnce(page([item("FN-1", "REC-1")]) as never);
const { result } = renderHook(() => useTaskRecommendations("project-a"));
await waitFor(() => expect(result.current.error).toBe("initial unavailable"));
await act(async () => { await result.current.refresh(); });
expect(result.current.error).toBeNull();
expect(result.current.items).toHaveLength(1);
});
it("records a create failure only against its composite row key", async () => {
mockFetchTaskRecommendations.mockResolvedValueOnce(page([item("FN-1", "same"), item("FN-2", "same")], { returnedRowCount: 2, totalRowCount: 2 }) as never);
mockCreateTaskFromRecommendation.mockRejectedValueOnce(new Error("create unavailable"));
const { result } = renderHook(() => useTaskRecommendations("project-a"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { await result.current.createTask("FN-1", "same"); });
expect(result.current.createStates.get("FN-1:same")).toEqual({ running: false, error: "create unavailable" });
expect(result.current.createStates.get("FN-2:same")).toBeUndefined();
});
it("drops stale project responses and restarts paging when the project changes", async () => {
let resolveOldPage: (value: ReturnType<typeof page>) => void = () => undefined;
mockFetchTaskRecommendations
.mockImplementationOnce(() => new Promise((resolve) => { resolveOldPage = resolve; }) as never)
.mockResolvedValueOnce(page([item("FN-new", "REC-new")]) as never);
const { result, rerender } = renderHook(({ projectId }) => useTaskRecommendations(projectId), { initialProps: { projectId: "project-a" } });
rerender({ projectId: "project-b" });
await waitFor(() => expect(mockFetchTaskRecommendations).toHaveBeenLastCalledWith("project-b", { limit: 50, offset: 0 }));
resolveOldPage(page([item("FN-old", "REC-old")]));
await waitFor(() => expect(result.current.items.map((entry) => entry.taskId)).toEqual(["FN-new"]));
});
it("tracks creates by task and recommendation id so equal recommendation ids do not collide", async () => {
mockFetchTaskRecommendations.mockResolvedValueOnce(page([item("FN-1", "same"), item("FN-2", "same")], { returnedRowCount: 2, totalRowCount: 2 }) as never);
mockCreateTaskFromRecommendation
.mockResolvedValueOnce({ task: { id: "FN-created-1" } } as never)
.mockResolvedValueOnce({ task: { id: "FN-created-2" } } as never);
const { result } = renderHook(() => useTaskRecommendations("project-a"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await Promise.all([
result.current.createTask("FN-1", "same"),
result.current.createTask("FN-2", "same"),
]);
});
expect(mockCreateTaskFromRecommendation).toHaveBeenCalledWith("FN-1", "same", "project-a");
expect(mockCreateTaskFromRecommendation).toHaveBeenCalledWith("FN-2", "same", "project-a");
expect(result.current.items.map((entry) => entry.recommendation.createdTaskId)).toEqual(["FN-created-1", "FN-created-2"]);
});
});