453 lines · 14.8 KB · TypeScript
RawDownload
import { ProjectId, ThreadId } from "@t3tools/contracts";import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; function makeImage(input: {  id: string;  previewUrl: string;  name?: string;  mimeType?: string;  sizeBytes?: number;  lastModified?: number;}): ComposerImageAttachment {  const name = input.name ?? "image.png";  const mimeType = input.mimeType ?? "image/png";  const sizeBytes = input.sizeBytes ?? 4;  const lastModified = input.lastModified ?? 1_700_000_000_000;  const file = new File([new Uint8Array(sizeBytes).fill(1)], name, {    type: mimeType,    lastModified,  });  return {    type: "image",    id: input.id,    name,    mimeType,    sizeBytes: file.size,    previewUrl: input.previewUrl,    file,  };} describe("composerDraftStore addImages", () => {  const threadId = ThreadId.makeUnsafe("thread-dedupe");  let originalRevokeObjectUrl: typeof URL.revokeObjectURL;  let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;   beforeEach(() => {    useComposerDraftStore.setState({      draftsByThreadId: {},      draftThreadsByThreadId: {},      projectDraftThreadIdByProjectId: {},    });    originalRevokeObjectUrl = URL.revokeObjectURL;    revokeSpy = vi.fn();    URL.revokeObjectURL = revokeSpy;  });   afterEach(() => {    URL.revokeObjectURL = originalRevokeObjectUrl;  });   it("deduplicates identical images in one batch by file signature", () => {    const first = makeImage({      id: "img-1",      previewUrl: "blob:first",      name: "same.png",      mimeType: "image/png",      sizeBytes: 12,      lastModified: 12345,    });    const duplicate = makeImage({      id: "img-2",      previewUrl: "blob:duplicate",      name: "same.png",      mimeType: "image/png",      sizeBytes: 12,      lastModified: 12345,    });     useComposerDraftStore.getState().addImages(threadId, [first, duplicate]);     const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];    expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]);    expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");  });   it("deduplicates against existing images across calls by file signature", () => {    const first = makeImage({      id: "img-a",      previewUrl: "blob:a",      name: "same.png",      mimeType: "image/png",      sizeBytes: 9,      lastModified: 777,    });    const duplicateLater = makeImage({      id: "img-b",      previewUrl: "blob:b",      name: "same.png",      mimeType: "image/png",      sizeBytes: 9,      lastModified: 999,    });     useComposerDraftStore.getState().addImage(threadId, first);    useComposerDraftStore.getState().addImage(threadId, duplicateLater);     const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];    expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]);    expect(revokeSpy).toHaveBeenCalledWith("blob:b");  });   it("does not revoke blob URLs that are still used by an accepted duplicate image", () => {    const first = makeImage({      id: "img-shared",      previewUrl: "blob:shared",    });    const duplicateSameUrl = makeImage({      id: "img-shared",      previewUrl: "blob:shared",    });     useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]);     const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];    expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]);    expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");  });}); describe("composerDraftStore clearComposerContent", () => {  const threadId = ThreadId.makeUnsafe("thread-clear");  let originalRevokeObjectUrl: typeof URL.revokeObjectURL;  let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;   beforeEach(() => {    useComposerDraftStore.setState({      draftsByThreadId: {},      draftThreadsByThreadId: {},      projectDraftThreadIdByProjectId: {},    });    originalRevokeObjectUrl = URL.revokeObjectURL;    revokeSpy = vi.fn();    URL.revokeObjectURL = revokeSpy;  });   afterEach(() => {    URL.revokeObjectURL = originalRevokeObjectUrl;  });   it("does not revoke blob preview URLs when clearing composer content", () => {    const first = makeImage({      id: "img-optimistic",      previewUrl: "blob:optimistic",    });    useComposerDraftStore.getState().addImage(threadId, first);     useComposerDraftStore.getState().clearComposerContent(threadId);     const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];    expect(draft).toBeUndefined();    expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");  });}); describe("composerDraftStore project draft thread mapping", () => {  const projectId = ProjectId.makeUnsafe("project-a");  const otherProjectId = ProjectId.makeUnsafe("project-b");  const threadId = ThreadId.makeUnsafe("thread-a");  const otherThreadId = ThreadId.makeUnsafe("thread-b");   beforeEach(() => {    useComposerDraftStore.setState({      draftsByThreadId: {},      draftThreadsByThreadId: {},      projectDraftThreadIdByProjectId: {},    });  });   it("stores and reads project draft thread ids via actions", () => {    const store = useComposerDraftStore.getState();    expect(store.getDraftThreadByProjectId(projectId)).toBeNull();    expect(store.getDraftThread(threadId)).toBeNull();     store.setProjectDraftThreadId(projectId, threadId, {      branch: "feature/test",      worktreePath: "/tmp/worktree-test",      createdAt: "2026-01-01T00:00:00.000Z",    });    expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({      threadId,      projectId,      branch: "feature/test",      worktreePath: "/tmp/worktree-test",      envMode: "worktree",      runtimeMode: "full-access",      interactionMode: "default",      createdAt: "2026-01-01T00:00:00.000Z",    });    expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({      projectId,      branch: "feature/test",      worktreePath: "/tmp/worktree-test",      envMode: "worktree",      runtimeMode: "full-access",      interactionMode: "default",      createdAt: "2026-01-01T00:00:00.000Z",    });  });   it("clears only matching project draft mapping entries", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId);    store.setPrompt(threadId, "hello");     store.clearProjectDraftThreadById(projectId, otherThreadId);    expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(      threadId,    );     store.clearProjectDraftThreadById(projectId, threadId);    expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();    expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();    expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();  });   it("clears project draft mapping by project id", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId);    store.setPrompt(threadId, "hello");    store.clearProjectDraftThreadId(projectId);    expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();    expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();    expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();  });   it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId);    store.setPrompt(threadId, "orphan me");     store.setProjectDraftThreadId(projectId, otherThreadId);     expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(      otherThreadId,    );    expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();    expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();  });   it("keeps composer drafts when the thread is still mapped by another project", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId);    store.setProjectDraftThreadId(otherProjectId, threadId);    store.setPrompt(threadId, "keep me");     store.clearProjectDraftThreadId(projectId);     expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();    expect(useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId).toBe(      threadId,    );    expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");  });   it("clears draft registration independently", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId);    store.clearDraftThread(threadId);    expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();    expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();  });   it("updates branch context on an existing draft thread", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId, {      branch: "main",      worktreePath: null,    });    store.setDraftThreadContext(threadId, {      branch: "feature/next",      worktreePath: "/tmp/feature-next",    });    expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(      threadId,    );    expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({      projectId,      branch: "feature/next",      worktreePath: "/tmp/feature-next",      envMode: "worktree",    });  });   it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId, {      branch: "main",      worktreePath: "/tmp/main-worktree",    });    const runtimeUndefinedOptions = {      branch: undefined,      worktreePath: undefined,    } as unknown as {      branch?: string | null;      worktreePath?: string | null;    };    store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);     expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({      projectId,      branch: "main",      worktreePath: "/tmp/main-worktree",      envMode: "worktree",    });  });   it("preserves worktree env mode without a worktree path", () => {    const store = useComposerDraftStore.getState();    store.setProjectDraftThreadId(projectId, threadId, {      branch: "feature/base",      worktreePath: null,      envMode: "worktree",    });    const runtimeUndefinedOptions = {      branch: undefined,      worktreePath: undefined,      envMode: undefined,    } as unknown as {      branch?: string | null;      worktreePath?: string | null;      envMode?: "local" | "worktree";    };    store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions);     expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({      projectId,      branch: "feature/base",      worktreePath: null,      envMode: "worktree",    });  });}); describe("composerDraftStore codex fast mode", () => {  const threadId = ThreadId.makeUnsafe("thread-service-tier");   beforeEach(() => {    useComposerDraftStore.setState({      draftsByThreadId: {},      draftThreadsByThreadId: {},      projectDraftThreadIdByProjectId: {},    });  });   it("stores codex fast mode in the draft", () => {    const store = useComposerDraftStore.getState();    store.setCodexFastMode(threadId, true);     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true);  });   it("clears codex fast mode when reset to the default", () => {    const store = useComposerDraftStore.getState();    store.setCodexFastMode(threadId, true);    store.setCodexFastMode(threadId, false);     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();  });}); describe("composerDraftStore setModel", () => {  const threadId = ThreadId.makeUnsafe("thread-model");   beforeEach(() => {    useComposerDraftStore.setState({      draftsByThreadId: {},      draftThreadsByThreadId: {},      projectDraftThreadIdByProjectId: {},    });  });   it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => {    const store = useComposerDraftStore.getState();     store.setModel(threadId, "gpt-5.3-codex");     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex");  });}); describe("composerDraftStore setProvider", () => {  const threadId = ThreadId.makeUnsafe("thread-provider");   beforeEach(() => {    useComposerDraftStore.setState({      draftsByThreadId: {},      draftThreadsByThreadId: {},      projectDraftThreadIdByProjectId: {},    });  });   it("persists provider-only selection even when prompt/model are empty", () => {    const store = useComposerDraftStore.getState();     store.setProvider(threadId, "codex");     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex");  });   it("removes empty provider-only draft when provider is reset", () => {    const store = useComposerDraftStore.getState();     store.setProvider(threadId, "codex");    store.setProvider(threadId, null);     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();  });}); describe("composerDraftStore runtime and interaction settings", () => {  const threadId = ThreadId.makeUnsafe("thread-settings");   beforeEach(() => {    useComposerDraftStore.setState({      draftsByThreadId: {},      draftThreadsByThreadId: {},      projectDraftThreadIdByProjectId: {},    });  });   it("stores runtime mode overrides in the composer draft", () => {    const store = useComposerDraftStore.getState();     store.setRuntimeMode(threadId, "approval-required");     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe(      "approval-required",    );  });   it("stores interaction mode overrides in the composer draft", () => {    const store = useComposerDraftStore.getState();     store.setInteractionMode(threadId, "plan");     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe(      "plan",    );  });   it("removes empty settings-only drafts when overrides are cleared", () => {    const store = useComposerDraftStore.getState();     store.setRuntimeMode(threadId, "approval-required");    store.setInteractionMode(threadId, "plan");    store.setRuntimeMode(threadId, null);    store.setInteractionMode(threadId, null);     expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();  });});