1import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
2import { act, useState, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6import {
7 togglePendingUserInputOptionSelection,
8 type PendingUserInputDraftAnswer,
9} from "../../pendingUserInput";
10
11vi.mock("../ui/collapsible", () => {
12 const Children = ({ children }: { children: ReactNode }) => <>{children}</>;
13 return {
14 Collapsible: Children,
15 CollapsiblePanel: Children,
16 CollapsibleTrigger: Children,
17 };
18});
19
20vi.mock("../ChatMarkdown", () => ({ default: ({ text }: { text: string }) => <p>{text}</p> }));
21
22import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
23
24const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
25
26const prompt = {
27 requestId: RuntimeRequestId.make("question-navigation"),
28 createdAt: "2026-10-09T00:00:00.000Z",
29 dismissible: false,
30 responseCapability: "live" as const,
31 questions: [0, 1, 2].map((index) => ({
32 id: `question-${index}`,
33 header: `Question ${index + 1}`,
34 question: `Choose for question ${index + 1}`,
35 options: [{ label: "Incremental", description: "One module at a time" }],
36 multiSelect: false,
37 })),
38};
39
40function Questions({ initialIndex = 0 }: { initialIndex?: number }) {
41 const [questionIndex, setQuestionIndex] = useState(initialIndex);
42 const [answers, setAnswers] = useState<Record<string, PendingUserInputDraftAnswer>>({});
43 const advance = () => setQuestionIndex(questionIndex + 1);
44 return (
45 <>
46 <output>{questionIndex}</output>
47 <button onClick={advance}>Next</button>
48 <button onClick={() => setQuestionIndex(questionIndex - 1)}>Previous</button>
49 <ComposerPendingUserInputPanel
50 pendingUserInputs={[prompt]}
51 cwd="/repo"
52 threadRef={threadRef}
53 disabled={false}
54 respondingRequestIds={[]}
55 answers={answers}
56 questionIndex={questionIndex}
57 onAdvance={advance}
58 onToggleOption={(questionId, value) => {
59 const question = prompt.questions.find((entry) => entry.id === questionId)!;
60 setAnswers((existing) => ({
61 ...existing,
62 [questionId]: togglePendingUserInputOptionSelection(
63 question,
64 existing[questionId],
65 value,
66 ),
67 }));
68 }}
69 onDismiss={() => {}}
70 />
71 </>
72 );
73}
74
75let renderer: ReactTestRenderer | undefined;
76beforeEach(() => {
77 vi.useFakeTimers();
78 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
79 vi.stubGlobal("window", {
80 setTimeout: globalThis.setTimeout,
81 clearTimeout: globalThis.clearTimeout,
82 });
83 vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
84});
85afterEach(async () => {
86 await act(async () => renderer?.unmount());
87 renderer = undefined;
88 vi.unstubAllGlobals();
89 vi.useRealTimers();
90});
91
92async function chooseOption() {
93 await act(async () => {
94 renderer!.root
95 .findAllByType("button")
96 .find((button) =>
97 button.findAllByType("span").some((span) => span.children.includes("Incremental")),
98 )!
99 .props.onClick();
100 });
101}
102
103async function navigate(label: string) {
104 await act(async () => {
105 renderer!.root
106 .findAllByType("button")
107 .find((button) => button.children.includes(label))!
108 .props.onClick();
109 });
110}
111
112function activeIndex() {
113 return renderer!.root.findByType("output").children.join("");
114}
115
116it("does not skip the next unanswered question after manual advance", async () => {
117 await act(async () => {
118 renderer = create(<Questions />);
119 });
120 await chooseOption();
121 await navigate("Next");
122 expect(activeIndex()).toBe("1");
123 await act(async () => vi.advanceTimersByTime(200));
124 expect(activeIndex()).toBe("1");
125
126 await chooseOption();
127 await act(async () => vi.advanceTimersByTime(200));
128 expect(activeIndex()).toBe("2");
129});
130
131it("does not undo previous-question navigation with a stale timer", async () => {
132 await act(async () => {
133 renderer = create(<Questions initialIndex={1} />);
134 });
135 await chooseOption();
136 await navigate("Previous");
137 await act(async () => vi.advanceTimersByTime(200));
138 expect(activeIndex()).toBe("0");
139});
140
141it("still auto-advances once after the answer draft rerenders the same question", async () => {
142 await act(async () => {
143 renderer = create(<Questions />);
144 });
145 await chooseOption();
146 await act(async () => vi.advanceTimersByTime(200));
147 expect(activeIndex()).toBe("1");
148 await act(async () => vi.advanceTimersByTime(200));
149 expect(activeIndex()).toBe("1");
150});