nexus/ui/src/hooks/usePromoteToProject.test.ts
Nexus Dev d2d41886fa feat(nexus): add promote-to-project state machine hook
Phase 12 — idle/prompting/creating/done/error state machine that drives
the 700ms compress-and-rise animation and calls projectsApi.create with
a brainstormer payload (goal, acceptance criteria, gates, engineer
template). Also exports buildCreatePayload helper for testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:19:01 +00:00

246 lines
7.1 KiB
TypeScript

// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Project } from "@paperclipai/shared";
import {
buildCreatePayload,
usePromoteToProject,
type BrainstormerPayload,
type PromoteState,
type UsePromoteToProjectResult,
} from "./usePromoteToProject";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function fakeProject(overrides: Partial<Project> = {}): Project {
return {
id: "proj-xyz",
companyId: "co-1",
urlKey: "my-new-project",
goalId: null,
goalIds: [],
goals: [],
name: "My New Project",
description: null,
status: "planned",
leadAgentId: null,
targetDate: null,
color: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: { kind: "none" } as unknown as Project["codebase"],
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Project;
}
// Harness: render a tiny component that calls the hook so we can drive it
// from tests via a ref. Keeps us in the Phase 8 createRoot+act pattern and
// avoids @testing-library/react-hooks (not installed).
function makeHarness() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let current: UsePromoteToProjectResult | null = null;
function Probe(props: {
conversationId: string | null;
companyId: string | null;
createProject?: (
companyId: string,
data: Record<string, unknown>,
) => Promise<Project>;
}) {
current = usePromoteToProject({
conversationId: props.conversationId,
companyId: props.companyId,
createProject: props.createProject,
});
return null;
}
function render(props: Parameters<typeof Probe>[0]) {
act(() => {
root.render(createElement(Probe, props));
});
}
function unmount() {
act(() => {
root.unmount();
});
if (container.parentNode) container.remove();
}
return {
render,
unmount,
get hook() {
if (!current) throw new Error("Hook not rendered yet");
return current;
},
getState(): PromoteState {
return current!.state;
},
};
}
describe("usePromoteToProject", () => {
let harness: ReturnType<typeof makeHarness>;
beforeEach(() => {
harness = makeHarness();
});
afterEach(() => {
harness.unmount();
});
const payload: BrainstormerPayload = {
goal: "Ship nexus phase 12",
acceptanceCriteria: ["animation ≤ 700ms", "ESC cancels"],
gates: ["pm-review"],
engineerTemplateId: undefined,
};
it("starts in idle state", () => {
harness.render({ conversationId: "c-1", companyId: "co-1" });
expect(harness.getState()).toEqual({ kind: "idle" });
});
it("transitions idle → prompting on startPrompting()", () => {
harness.render({ conversationId: "c-1", companyId: "co-1" });
act(() => harness.hook.startPrompting());
expect(harness.getState()).toEqual({ kind: "prompting", conversationId: "c-1" });
});
it("does not transition when conversationId is null", () => {
harness.render({ conversationId: null, companyId: "co-1" });
act(() => harness.hook.startPrompting());
expect(harness.getState()).toEqual({ kind: "idle" });
});
it("transitions prompting → creating → done on successful confirm()", async () => {
const createProject = vi.fn().mockResolvedValue(
fakeProject({ id: "proj-7", urlKey: "nexus-phase-12", name: "Nexus Phase 12" }),
);
harness.render({ conversationId: "c-1", companyId: "co-1", createProject });
act(() => harness.hook.startPrompting());
await act(async () => {
await harness.hook.confirm(payload);
});
expect(createProject).toHaveBeenCalledTimes(1);
const [calledCompany, calledBody] = createProject.mock.calls[0]!;
expect(calledCompany).toBe("co-1");
expect(calledBody).toMatchObject({
name: "Ship nexus phase 12",
description: "Ship nexus phase 12",
status: "planned",
originConversationId: "c-1",
brainstormer: {
goal: "Ship nexus phase 12",
acceptanceCriteria: ["animation ≤ 700ms", "ESC cancels"],
gates: ["pm-review"],
},
});
expect(harness.getState()).toEqual({
kind: "done",
projectId: "proj-7",
projectSlug: "nexus-phase-12",
projectName: "Nexus Phase 12",
});
});
it("transitions to error state when createProject rejects", async () => {
const createProject = vi.fn().mockRejectedValue(new Error("boom"));
harness.render({ conversationId: "c-1", companyId: "co-1", createProject });
act(() => harness.hook.startPrompting());
await act(async () => {
await harness.hook.confirm(payload);
});
expect(harness.getState()).toEqual({ kind: "error", message: "boom" });
});
it("cancel() returns to idle from prompting", () => {
harness.render({ conversationId: "c-1", companyId: "co-1" });
act(() => harness.hook.startPrompting());
expect(harness.getState().kind).toBe("prompting");
act(() => harness.hook.cancel());
expect(harness.getState()).toEqual({ kind: "idle" });
});
it("refuses confirm() when companyId is null", async () => {
const createProject = vi.fn();
harness.render({ conversationId: "c-1", companyId: null, createProject });
act(() => harness.hook.startPrompting());
await act(async () => {
await harness.hook.confirm(payload);
});
expect(createProject).not.toHaveBeenCalled();
expect(harness.getState().kind).toBe("error");
});
});
describe("buildCreatePayload", () => {
it("derives name from the first 60 chars of goal first line", () => {
const body = buildCreatePayload(
{
goal: "Rewrite the promote flow\nwith animated transitions",
acceptanceCriteria: [],
gates: [],
},
"conv-1",
);
expect(body.name).toBe("Rewrite the promote flow");
});
it("falls back to 'Untitled project' when goal is empty", () => {
const body = buildCreatePayload(
{ goal: "", acceptanceCriteria: [], gates: [] },
"conv-1",
);
expect(body.name).toBe("Untitled project");
expect(body.description).toBeUndefined();
});
it("truncates goal first line to 60 chars", () => {
const longLine = "a".repeat(120);
const body = buildCreatePayload(
{ goal: longLine, acceptanceCriteria: [], gates: [] },
"conv-1",
);
expect((body.name as string).length).toBe(60);
});
it("includes brainstormer metadata under brainstormer key", () => {
const body = buildCreatePayload(
{
goal: "G",
acceptanceCriteria: ["a", "b"],
gates: ["pm-review"],
engineerTemplateId: "tpl-1",
},
"conv-2",
);
expect(body.brainstormer).toEqual({
goal: "G",
acceptanceCriteria: ["a", "b"],
gates: ["pm-review"],
engineerTemplateId: "tpl-1",
});
expect(body.originConversationId).toBe("conv-2");
});
});