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>
131 lines
6.5 KiB
TypeScript
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"]);
|
|
});
|
|
});
|