fa7d88bee6
Co-authored-by: Kandev Agent <agent@kandev.dev>
163 lines
5.3 KiB
TypeScript
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();
|
|
});
|
|
});
|