feat: add draft workspace routes
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionState } from "../../app/state.js";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import type { DraftWorkspaceClient } from "../domain/draft-workspace-client.js";
|
||||
import type {
|
||||
DraftWorkspace,
|
||||
DraftWorkspacePage,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { createDraftWorkspaceClient } from "../domain/draft-workspace-client.js";
|
||||
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
vi.mock("../context.js", () => ({
|
||||
useConsoleWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../domain/draft-workspace-client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../domain/draft-workspace-client.js")>(
|
||||
"../domain/draft-workspace-client.js",
|
||||
);
|
||||
return { ...actual, createDraftWorkspaceClient: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
|
||||
const mockedCreateDraftWorkspaceClient = vi.mocked(createDraftWorkspaceClient);
|
||||
|
||||
const connectedState = {
|
||||
phase: "connected",
|
||||
connectedTarget: "http://workflow.example/rpc",
|
||||
} as ConnectionState;
|
||||
|
||||
const disconnectedState = {
|
||||
phase: "not_configured",
|
||||
connectedTarget: null,
|
||||
} as ConnectionState;
|
||||
|
||||
const workspace = (
|
||||
workspaceId: string,
|
||||
overrides: Partial<DraftWorkspace> = {},
|
||||
): DraftWorkspace => ({
|
||||
workspaceId,
|
||||
revision: 1,
|
||||
title: `${workspaceId} title`,
|
||||
status: "valid",
|
||||
diagnostics: [],
|
||||
summary: {
|
||||
name: workspaceId,
|
||||
start: "start",
|
||||
stepCount: 1,
|
||||
routeCount: 1,
|
||||
steps: ["start"],
|
||||
},
|
||||
draft: { nodes: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const page = (items: ReadonlyArray<DraftWorkspace>): DraftWorkspacePage => ({
|
||||
items: [...items],
|
||||
});
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const client = {
|
||||
list: vi.fn<DraftWorkspaceClient["list"]>(),
|
||||
load: vi.fn<DraftWorkspaceClient["load"]>(),
|
||||
} satisfies DraftWorkspaceClient;
|
||||
|
||||
const readExecutor = {} as ConsoleReadExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
client.list.mockReset();
|
||||
client.load.mockReset();
|
||||
mockedCreateDraftWorkspaceClient.mockReset();
|
||||
mockedCreateDraftWorkspaceClient.mockReturnValue(client);
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: connectedState.connectedTarget,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDraftWorkspace", () => {
|
||||
it("loads the list and URL-owned detail through the draft client", async () => {
|
||||
client.list.mockResolvedValue(page([workspace("draft-report")]));
|
||||
client.load.mockResolvedValue(workspace("draft-report"));
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-report"));
|
||||
|
||||
await waitFor(() => expect(result.current.listPhase).toBe("ready"));
|
||||
await waitFor(() => expect(result.current.detailPhase).toBe("ready"));
|
||||
|
||||
expect(client.list).toHaveBeenCalledOnce();
|
||||
expect(client.load).toHaveBeenCalledWith("draft-report");
|
||||
expect(result.current.items[0]?.workspaceId).toBe("draft-report");
|
||||
expect(result.current.selected?.workspaceId).toBe("draft-report");
|
||||
});
|
||||
|
||||
it("ignores a late detail response after the URL workspace changes", async () => {
|
||||
client.list.mockResolvedValue(page([]));
|
||||
const firstDetail = deferred<DraftWorkspace>();
|
||||
const secondDetail = deferred<DraftWorkspace>();
|
||||
client.load
|
||||
.mockReturnValueOnce(firstDetail.promise)
|
||||
.mockReturnValueOnce(secondDetail.promise);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspaceId }: { workspaceId: string | null }) => useDraftWorkspace(workspaceId),
|
||||
{ initialProps: { workspaceId: "draft-first" } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(client.load).toHaveBeenCalledWith("draft-first"));
|
||||
rerender({ workspaceId: "draft-second" });
|
||||
await waitFor(() => expect(client.load).toHaveBeenCalledWith("draft-second"));
|
||||
|
||||
firstDetail.resolve(workspace("draft-first"));
|
||||
secondDetail.resolve(workspace("draft-second"));
|
||||
|
||||
await waitFor(() => expect(result.current.selected?.workspaceId).toBe("draft-second"));
|
||||
expect(result.current.selected?.workspaceId).not.toBe("draft-first");
|
||||
});
|
||||
|
||||
it("preserves the loaded list while refresh reloads the relevant reads", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([workspace("draft-old")]))
|
||||
.mockReturnValueOnce(deferred<DraftWorkspacePage>().promise);
|
||||
client.load
|
||||
.mockResolvedValueOnce(workspace("draft-old"))
|
||||
.mockReturnValueOnce(deferred<DraftWorkspace>().promise);
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-old"));
|
||||
await waitFor(() => expect(result.current.selected?.workspaceId).toBe("draft-old"));
|
||||
|
||||
act(() => result.current.refresh());
|
||||
|
||||
expect(result.current.items[0]?.workspaceId).toBe("draft-old");
|
||||
expect(client.list).toHaveBeenCalledTimes(2);
|
||||
expect(client.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears stale data and reloads both reads after reconnect", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([workspace("draft-old")]))
|
||||
.mockResolvedValueOnce(page([workspace("draft-new")]))
|
||||
.mockResolvedValueOnce(page([workspace("draft-new")]));
|
||||
client.load
|
||||
.mockResolvedValueOnce(workspace("draft-old"))
|
||||
.mockResolvedValueOnce(workspace("draft-new"));
|
||||
|
||||
const { result, rerender } = renderHook(() => useDraftWorkspace("draft-new"));
|
||||
await waitFor(() => expect(result.current.listPhase).toBe("ready"));
|
||||
await waitFor(() => expect(result.current.detailPhase).toBe("ready"));
|
||||
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: connectedState,
|
||||
connectedTarget: "http://new-workflow.example/rpc",
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: {} as ConsoleReadExecutor,
|
||||
});
|
||||
rerender();
|
||||
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(result.current.selected).toBeNull();
|
||||
await waitFor(() => expect(result.current.items[0]?.workspaceId).toBe("draft-new"));
|
||||
expect(client.list).toHaveBeenCalledTimes(2);
|
||||
expect(client.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports list and detail errors without claiming ready data", async () => {
|
||||
client.list.mockRejectedValue(new Error("list failed"));
|
||||
client.load.mockRejectedValue(new Error("detail failed"));
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-broken"));
|
||||
|
||||
await waitFor(() => expect(result.current.listPhase).toBe("error"));
|
||||
await waitFor(() => expect(result.current.detailPhase).toBe("error"));
|
||||
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(result.current.listMessage).toBe("list failed");
|
||||
expect(result.current.detailMessage).toBe("detail failed");
|
||||
});
|
||||
|
||||
it("does not request drafts while disconnected", () => {
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: disconnectedState,
|
||||
connectedTarget: null,
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDraftWorkspace("draft-report"));
|
||||
|
||||
expect(result.current.listPhase).toBe("disconnected");
|
||||
expect(result.current.detailPhase).toBe("disconnected");
|
||||
expect(client.list).not.toHaveBeenCalled();
|
||||
expect(client.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user