1import {
2 ASSISTANT_CITATION_MAX_COMMENT_LENGTH,
3 EnvironmentId,
4 MessageId,
5 ThreadId,
6} from "@t3tools/contracts";
7import { act, useState, type ReactNode } from "react";
8import { create, type ReactTestRenderer } from "react-test-renderer";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
12
13const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() }));
14vi.mock("./AssistantCitationSource", () => ({
15 observeAssistantCitationCommentSource: mocks.observeSource,
16}));
17vi.mock("@tanstack/react-router", () => ({
18 useNavigate: () => vi.fn(),
19 Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
20}));
21// Keep the real chip/editor lifecycle while replacing DOM positioning and floating layers.
22vi.mock("../ui/popover", () => ({
23 Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
24 PopoverTrigger: ({ children }: { children: ReactNode }) => <button>{children}</button>,
25 PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
26}));
27vi.mock("../ui/button", () => ({
28 Button: (props: React.ComponentProps<"button">) => <button {...props} />,
29}));
30
31import { PopoverPopup } from "../ui/popover";
32import { AssistantCitationChip } from "./AssistantCitationChip";
33
34const citation = {
35 version: 1 as const,
36 environmentId: EnvironmentId.make("environment"),
37 threadId: ThreadId.make("thread"),
38 messageId: MessageId.make("source"),
39 text: "hello",
40 start: 0,
41 end: 5,
42 prefix: "",
43 suffix: "",
44};
45// The observer owns DOM access; these identities let us check which anchor survives.
46const sourceAnchor = {
47 source: {},
48 range: {},
49 viewport: {},
50} as AssistantCitationSourceAnchor;
51
52let renderer: ReactTestRenderer;
53
54function mount(onSave = vi.fn(() => true)) {
55 function Composer() {
56 const [open, setOpen] = useState(true);
57 return (
58 <AssistantCitationChip
59 citation={citation}
60 composer
61 commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
62 />
63 );
64 }
65 act(() => {
66 renderer = create(<Composer />);
67 });
68 return onSave;
69}
70
71function typeComment(value: string) {
72 act(() => renderer.root.findByType("textarea").props.onChange({ currentTarget: { value } }));
73}
74
75function removeSource() {
76 const { onUnavailable } = mocks.observeSource.mock.lastCall![0];
77 act(() => onUnavailable());
78}
79
80function clickButton(label: string) {
81 act(() =>
82 renderer.root
83 .findAllByType("button")
84 .find((button) => button.props.children === label)!
85 .props.onClick(),
86 );
87}
88
89beforeEach(() => {
90 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
91 mocks.observeSource.mockReset().mockReturnValue(mocks.dispose);
92 mocks.dispose.mockClear();
93});
94
95afterEach(() => {
96 act(() => renderer?.unmount());
97 vi.unstubAllGlobals();
98});
99
100describe("citation comment source disappearance", () => {
101 it("preserves an over-length draft at the composer until it can be shortened and saved", () => {
102 const onSave = mount();
103 const draft = "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1);
104 typeComment(draft);
105 removeSource();
106
107 expect(renderer.root.findByType("textarea").props.value).toBe(draft);
108 expect(renderer.root.findByType("textarea").props["aria-invalid"]).toBe(true);
109 expect(renderer.root.findByType(PopoverPopup).props.anchor).toBeUndefined();
110 expect(renderer.root.findByType(PopoverPopup).props.side).toBe("top");
111 expect(onSave).not.toHaveBeenCalled();
112 expect(mocks.dispose).toHaveBeenCalled();
113 expect(mocks.observeSource).toHaveBeenCalledTimes(1);
114
115 typeComment("shortened comment");
116 clickButton("Save");
117 expect(onSave).toHaveBeenCalledWith("shortened comment");
118 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
119 });
120
121 it("preserves a rejected save and allows a later retry", () => {
122 const onSave = mount(vi.fn(() => false));
123 typeComment("keep this draft");
124 removeSource();
125
126 expect(onSave).toHaveBeenCalledWith("keep this draft");
127 expect(renderer.root.findByType("textarea").props.value).toBe("keep this draft");
128 expect(renderer.root.findByType(PopoverPopup).props.anchor).toBeUndefined();
129 onSave.mockReturnValue(true);
130 clickButton("Save");
131 expect(onSave).toHaveBeenLastCalledWith("keep this draft");
132 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
133 });
134
135 it("still allows explicit cancellation after source disappearance", () => {
136 const onSave = mount(vi.fn(() => false));
137 typeComment("discard this draft");
138 removeSource();
139 onSave.mockClear();
140 clickButton("Cancel");
141 expect(onSave).not.toHaveBeenCalled();
142 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
143 });
144
145 it("saves and closes when the source disappears with a valid draft", () => {
146 const onSave = mount();
147 typeComment("saved comment");
148 removeSource();
149 expect(onSave).toHaveBeenCalledWith("saved comment");
150 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
151 });
152});