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>
246 lines
7.1 KiB
TypeScript
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");
|
|
});
|
|
});
|