1import { act, createRef, useContext, useState } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, expect, it, vi } from "vite-plus/test";
4import {
5 EnvironmentId,
6 ThreadId,
7 type OrchestrationV2SearchThreadResult,
8} from "@t3tools/contracts";
9import {
10 ThreadFind,
11 ThreadFindProvider,
12 ThreadFindTimelineContext,
13 type ThreadFindControls,
14} from "./ThreadFindProvider";
15import type { ThreadFindBar } from "./ThreadFindBar";
16
17const query = vi.hoisted(() => ({
18 value: {
19 data: null as OrchestrationV2SearchThreadResult | null,
20 isPending: false,
21 error: null,
22 refresh: vi.fn(),
23 },
24 listeners: new Set<() => void>(),
25}));
26let bar: Parameters<typeof ThreadFindBar>[0];
27vi.mock("./ThreadFindBar", () => ({
28 ThreadFindBar: (props: typeof bar) => {
29 bar = props;
30 return null;
31 },
32}));
33vi.mock("~/state/orchestration", () => ({
34 orchestrationEnvironment: { threadFind: (target: unknown) => target },
35}));
36vi.mock("~/state/queries", () => ({ useDebouncedValue: <T,>(value: T) => value }));
37vi.mock("~/state/query", async () => {
38 const { useSyncExternalStore } = await import("react");
39 return {
40 useEnvironmentQuery: (atom: unknown) => {
41 const value = useSyncExternalStore(
42 (listener) => {
43 query.listeners.add(listener);
44 return () => query.listeners.delete(listener);
45 },
46 () => query.value,
47 );
48 return { ...value, data: atom ? value.data : null };
49 },
50 };
51});
52vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
53
54let renderer: ReactTestRenderer | undefined;
55afterEach(async () => {
56 if (renderer) await act(async () => renderer?.unmount());
57 renderer = undefined;
58 query.listeners.clear();
59 vi.unstubAllGlobals();
60});
61
62it("keeps navigation and pending responses out of the surrounding chat and unrelated children", async () => {
63 vi.stubGlobal("window", new EventTarget());
64 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65 const controls = createRef<ThreadFindControls>();
66 const parentRender = vi.fn();
67 const composerRender = vi.fn();
68 const timelineRender = vi.fn();
69 function Composer() {
70 composerRender();
71 return null;
72 }
73 function Timeline() {
74 timelineRender(useContext(ThreadFindTimelineContext));
75 return null;
76 }
77 function Chat() {
78 parentRender();
79 const [, setOpen] = useState(false);
80 return (
81 <ThreadFindProvider
82 thread={{ environmentId: EnvironmentId.make("dev"), threadId: ThreadId.make("thread") }}
83 enabled
84 content={undefined}
85 controlsRef={controls}
86 onOpenChange={setOpen}
87 >
88 <ThreadFind onClose={() => {}} />
89 <Timeline />
90 <Composer />
91 </ThreadFindProvider>
92 );
93 }
94 const response = (index: number): OrchestrationV2SearchThreadResult => ({
95 totalMatches: 3,
96 activeIndex: index,
97 snapshotSequence: 1,
98 match: { entryId: "message", runId: null, occurrence: index },
99 });
100 query.value = { ...query.value, data: response(0), isPending: false };
101 await act(async () => {
102 renderer = create(<Chat />);
103 });
104 await act(async () => controls.current?.open());
105 await act(async () => bar.onQueryChange("COD4"));
106 const parentCount = parentRender.mock.calls.length;
107 const composerCount = composerRender.mock.calls.length;
108 const timelineCount = timelineRender.mock.calls.length;
109 await act(async () => {
110 query.value = { ...query.value, data: null, isPending: true };
111 for (const listener of query.listeners) listener();
112 bar.onNext();
113 });
114 expect(parentRender).toHaveBeenCalledTimes(parentCount);
115 expect(composerRender).toHaveBeenCalledTimes(composerCount);
116 expect(timelineRender).toHaveBeenCalledTimes(timelineCount);
117 await act(async () => {
118 query.value = { ...query.value, data: response(1), isPending: false };
119 for (const listener of query.listeners) listener();
120 });
121 expect(parentRender).toHaveBeenCalledTimes(parentCount);
122 expect(composerRender).toHaveBeenCalledTimes(composerCount);
123 expect(timelineRender).toHaveBeenCalledTimes(timelineCount + 1);
124 expect(timelineRender.mock.lastCall?.[0]?.activeFindMatch?.occurrence).toBe(1);
125 await act(async () => controls.current?.close());
126 expect(parentRender.mock.calls.length).toBeGreaterThan(parentCount);
127});