Files
kandev/apps/web/lib/plugins/composer-capability.test.tsx
2026-08-12 20:29:18 +01:00

163 lines
5.3 KiB
TypeScript

import { StrictMode, type ReactNode } from "react";
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
composerIdentity,
composerInsertionText,
useStablePluginComposerCapability,
} from "./composer-capability";
/** One mounted task-chat composer, used wherever the identity is incidental. */
const COMPOSER = "task-chat:t1:s1";
function strictWrapper({ children }: { children: ReactNode }) {
return <StrictMode>{children}</StrictMode>;
}
describe("composerInsertionText", () => {
it("trims and ignores an empty transcript", () => {
expect(composerInsertionText(" ", "a")).toBe("");
expect(composerInsertionText("", "")).toBe("");
});
it("adds one space only when the preceding character is not whitespace", () => {
expect(composerInsertionText(" world ", "o")).toBe(" world");
expect(composerInsertionText("world", " ")).toBe("world");
expect(composerInsertionText("world", "\n")).toBe("world");
expect(composerInsertionText("world", "")).toBe("world");
});
});
describe("composerIdentity", () => {
it("separates surfaces, tasks and sessions", () => {
const ids = [
composerIdentity("task-chat", "t1", "s1"),
composerIdentity("task-chat", "t1", "s2"),
composerIdentity("task-chat", "t2", "s1"),
composerIdentity("quick-chat", "t1", "s1"),
composerIdentity("task-create", null, null),
composerIdentity("new-session", "t1", null),
];
expect(new Set(ids).size).toBe(ids.length);
});
it("is stable for the same composer", () => {
expect(composerIdentity("task-chat", "t1", "s1")).toBe(
composerIdentity("task-chat", "t1", "s1"),
);
});
});
describe("useStablePluginComposerCapability", () => {
it("keeps one capability object across re-renders", () => {
const { result, rerender } = renderHook(
(submittable: boolean) =>
useStablePluginComposerCapability(
{
insertText: () => true,
focus: () => true,
submit: async () => submittable,
},
COMPOSER,
),
{ initialProps: false },
);
const first = result.current;
rerender(true);
expect(result.current).toBe(first);
});
it("revalidates the native gate at call time rather than at handout time", async () => {
const { result, rerender } = renderHook(
(submittable: boolean) =>
useStablePluginComposerCapability(
{
insertText: () => true,
focus: () => true,
submit: async () => submittable,
},
COMPOSER,
),
{ initialProps: false },
);
// A plugin that captured the capability while submit was blocked...
const captured = result.current;
await expect(captured.submit()).resolves.toEqual({ status: "blocked" });
// ...still submits once the native form becomes submittable.
rerender(true);
await expect(captured.submit()).resolves.toEqual({ status: "submitted" });
});
it("survives a StrictMode remount so a long-running plugin action still works", async () => {
const insertText = vi.fn(() => true);
const { result } = renderHook(
() =>
useStablePluginComposerCapability(
{
insertText,
focus: () => true,
submit: async () => true,
},
COMPOSER,
),
{ wrapper: strictWrapper },
);
expect(result.current.insertText("hello")).toEqual({ status: "inserted" });
await expect(result.current.submit()).resolves.toEqual({ status: "submitted" });
expect(insertText).toHaveBeenCalledWith("hello");
});
it("revokes the previous handle when the composer's identity changes", async () => {
// Task chat is not remounted on a session switch (ChatInputContainer is
// keyed only by clarificationKey), so without an identity key a handle
// captured on session A would retarget session B's editor.
const { result, rerender } = renderHook(
(identity: string) =>
useStablePluginComposerCapability(
{
insertText: () => true,
focus: () => true,
submit: async () => true,
},
identity,
),
{ initialProps: COMPOSER },
);
const capturedOnSessionA = result.current;
rerender("task-chat:t1:s2");
expect(result.current).not.toBe(capturedOnSessionA);
expect(capturedOnSessionA.insertText("late transcript")).toEqual({ status: "unavailable" });
await expect(capturedOnSessionA.submit()).resolves.toEqual({ status: "unavailable" });
// The freshly issued handle drives the new composer normally.
expect(result.current.insertText("hello")).toEqual({ status: "inserted" });
});
it("fails closed once the composer unmounts", async () => {
const insertText = vi.fn(() => true);
const { result, unmount } = renderHook(() =>
useStablePluginComposerCapability(
{
insertText,
focus: () => true,
submit: async () => true,
},
COMPOSER,
),
);
const captured = result.current;
unmount();
expect(captured.insertText("hello")).toEqual({ status: "unavailable" });
expect(captured.focus()).toEqual({ status: "unavailable" });
await expect(captured.submit()).resolves.toEqual({ status: "unavailable" });
expect(insertText).not.toHaveBeenCalled();
});
});