1// @vitest-environment jsdom
2
3import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
4import {
5 serializeAssistantCitation,
6 withAssistantCitationComment,
7} from "@t3tools/shared/assistantCitations";
8import { act, createRef, useState, type ReactNode } from "react";
9import { createRoot, type Root } from "react-dom/client";
10import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
11
12import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
13
14vi.mock("./chat/AssistantCitationSource", () => ({
15 observeAssistantCitationCommentSource: () => () => {},
16}));
17vi.mock("@tanstack/react-router", () => ({
18 useNavigate: () => () => {},
19 Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
20}));
21// Keep the real chip and comment editor while replacing floating layers.
22vi.mock("./ui/popover", () => ({
23 Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
24 PopoverTrigger: ({ "aria-label": label }: { "aria-label": string }) => (
25 <button aria-label={label} />
26 ),
27 PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
28 PopoverClose: ({ children }: { children: ReactNode }) => <>{children}</>,
29}));
30
31import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
32
33const citation = {
34 version: 1 as const,
35 environmentId: EnvironmentId.make("environment"),
36 threadId: ThreadId.make("thread"),
37 messageId: MessageId.make("source"),
38 text: "hello",
39 start: 0,
40 end: 5,
41 prefix: "",
42 suffix: "",
43};
44const promptBefore = `See ${serializeAssistantCitation(citation)} `;
45
46let root: Root;
47let container: HTMLDivElement;
48
49beforeEach(() => {
50 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
51 Object.defineProperty(Range.prototype, "getClientRects", {
52 configurable: true,
53 value: () => [],
54 });
55 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
56 configurable: true,
57 value: () => new DOMRect(),
58 });
59 container = document.createElement("div");
60 document.body.append(container);
61 root = createRoot(container);
62});
63
64afterEach(async () => {
65 await act(async () => root.unmount());
66 container.remove();
67 vi.unstubAllGlobals();
68});
69
70// Mirrors ChatComposer: a pending question shows its answer in the same editor.
71function Composer({
72 question,
73 editorRef,
74}: {
75 question: boolean;
76 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
77}) {
78 const [prompt, setPrompt] = useState(promptBefore);
79 const [answer, setAnswer] = useState("");
80 return (
81 <>
82 <output data-prompt={prompt} data-answer={answer} />
83 <ComposerPromptEditor
84 editorRef={editorRef}
85 value={question ? answer : prompt}
86 literalText={question}
87 cursor={0}
88 contextRecords={new Map()}
89 skills={[]}
90 disabled={false}
91 placeholder=""
92 onChange={(next) => (question ? setAnswer(next) : setPrompt(next))}
93 onCitationDraftSave={(replacedValue, savedValue) => {
94 if (replacedValue === prompt) setPrompt(savedValue);
95 }}
96 onPaste={() => {}}
97 />
98 </>
99 );
100}
101
102it("keeps an unsaved citation comment when a question takes the composer", async () => {
103 const editorRef = createRef<ComposerPromptEditorHandle>();
104 const drafts = () => container.querySelector("output")?.dataset;
105
106 await act(async () => root.render(<Composer question={false} editorRef={editorRef} />));
107 await act(async () =>
108 editorRef.current?.requestCitationComment({
109 value: promptBefore,
110 citationStart: "See ".length,
111 sourceAnchor: {} as AssistantCitationSourceAnchor,
112 insertedSpaces: { before: false, after: true },
113 }),
114 );
115 const textarea = container.querySelector("textarea");
116 if (!textarea) throw new Error("Comment editor did not open");
117 await act(async () => {
118 const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
119 setValue?.call(textarea, "check this");
120 textarea.dispatchEvent(new Event("input", { bubbles: true }));
121 });
122
123 await act(async () => root.render(<Composer question editorRef={editorRef} />));
124 expect(drafts()?.answer).toBe("");
125 await act(async () => root.render(<Composer question={false} editorRef={editorRef} />));
126
127 expect(drafts()?.prompt).toBe(
128 `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `,
129 );
130 expect(container.querySelector('[aria-label="Edit citation comment"]')).not.toBeNull();
131});