1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import { formatInlineContextReference } from "./lib/composerContextReferences";
4import { threadContextRecord, threadContextReference } from "./lib/composerContextRecords";
5
6import {
7 buildPendingUserInputAnswers,
8 carryDisplacedCustomAnswerIntoPrompt,
9 countAnsweredPendingUserInputQuestions,
10 derivePendingUserInputProgress,
11 findFirstUnansweredPendingUserInputQuestionIndex,
12 projectPendingUserInputAnswer,
13 resolvePendingUserInputAnswer,
14 setPendingUserInputCustomAnswer,
15 togglePendingUserInputOptionSelection,
16} from "./pendingUserInput";
17
18describe("projectPendingUserInputAnswer", () => {
19 const record = threadContextRecord(
20 {
21 environmentId: EnvironmentId.make("env-answer"),
22 threadId: ThreadId.make("reference-thread"),
23 },
24 "Reference work",
25 );
26
27 it("sends thread chips as readable links with the actual thread ID", () => {
28 const answer = `Compare ${formatInlineContextReference(threadContextReference(record))}`;
29 expect(projectPendingUserInputAnswer(answer, [record])).toBe(
30 "Compare [Reference work](t3-thread://v1/reference-thread)",
31 );
32 });
33
34 it("leaves plain answers unchanged when the draft has unreferenced thread records", () => {
35 expect(projectPendingUserInputAnswer("Plain answer", [record])).toBe("Plain answer");
36 });
37
38 it("preserves option values and exact answer text", () => {
39 const options = [" first\t", "second"];
40 expect(projectPendingUserInputAnswer(options, [record])).toBe(options);
41 expect(projectPendingUserInputAnswer(" Edited answer\n", [record])).toBe(" Edited answer\n");
42 });
43});
44
45const singleSelectQuestion = {
46 id: "scope",
47 header: "Scope",
48 question: "What should the plan target first?",
49 options: [
50 {
51 label: "Orchestration-first",
52 description: "Focus on orchestration first",
53 },
54 ],
55 multiSelect: false,
56} as const;
57
58const multiSelectQuestion = {
59 id: "areas",
60 header: "Areas",
61 question: "Which areas should this change cover?",
62 options: [
63 {
64 label: "Server",
65 description: "Server",
66 },
67 {
68 label: "Web",
69 description: "Web",
70 },
71 ],
72 multiSelect: true,
73} as const;
74
75const nativeChoiceQuestion = {
76 id: "result",
77 header: "Result",
78 question: "Which result should be used?",
79 options: [
80 { value: " first\t", label: "Result", description: "First result" },
81 { value: "second", label: "Result", description: "Second result" },
82 ],
83 allowCustomAnswer: false,
84 multiSelect: false,
85} as const;
86
87describe("resolvePendingUserInputAnswer", () => {
88 it("preserves exact editor text, including a deliberately cleared answer", () => {
89 const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" };
90 expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe(
91 question.initialAnswer,
92 );
93 expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe(
94 " Edited message\n\n",
95 );
96 expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe("");
97 expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n");
98 expect(
99 setPendingUserInputCustomAnswer(
100 { selectedOptionValues: ["Orchestration-first"] },
101 "",
102 question,
103 ),
104 ).toEqual({ customAnswer: "" });
105 const questions = [question, { ...singleSelectQuestion, id: "follow-up" }];
106 const drafts = { scope: { customAnswer: "" } };
107 expect(findFirstUnansweredPendingUserInputQuestionIndex(questions, drafts)).toBe(1);
108 expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({
109 customAnswer: "",
110 resolvedAnswer: "",
111 canAdvance: true,
112 isComplete: true,
113 });
114 });
115
116 it("prefers a custom answer over selected options", () => {
117 expect(
118 resolvePendingUserInputAnswer(singleSelectQuestion, {
119 selectedOptionValues: ["Orchestration-first"],
120 customAnswer: "Keep the existing envelope for one release",
121 }),
122 ).toBe("Keep the existing envelope for one release");
123 });
124
125 it("falls back to the selected option for single-select questions", () => {
126 expect(
127 resolvePendingUserInputAnswer(singleSelectQuestion, {
128 selectedOptionValues: ["Orchestration-first"],
129 }),
130 ).toBe("Orchestration-first");
131 });
132
133 it("returns all selected labels for multi-select questions", () => {
134 expect(
135 resolvePendingUserInputAnswer(multiSelectQuestion, {
136 selectedOptionValues: ["Server", "Web"],
137 }),
138 ).toEqual(["Server", "Web"]);
139 });
140
141 it("clears the preset selection when a custom answer is entered", () => {
142 expect(
143 setPendingUserInputCustomAnswer(
144 {
145 selectedOptionValues: ["Server", "Web"],
146 },
147 "doesn't matter",
148 ),
149 ).toEqual({
150 customAnswer: "doesn't matter",
151 });
152 });
153
154 it("does not replace a required choice with a custom answer", () => {
155 expect(
156 resolvePendingUserInputAnswer(nativeChoiceQuestion, {
157 selectedOptionValues: ["second"],
158 customAnswer: "Use another result",
159 }),
160 ).toBe("second");
161 });
162
163 it("does not submit labels or unknown values when an option has a value", () => {
164 expect(
165 resolvePendingUserInputAnswer(nativeChoiceQuestion, {
166 selectedOptionValues: ["Result", "unknown"],
167 }),
168 ).toBeNull();
169 });
170});
171
172describe("togglePendingUserInputOptionSelection", () => {
173 it("toggles options for multi-select questions", () => {
174 expect(togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server")).toEqual(
175 {
176 customAnswer: "",
177 selectedOptionValues: ["Server"],
178 },
179 );
180
181 expect(
182 togglePendingUserInputOptionSelection(
183 multiSelectQuestion,
184 {
185 selectedOptionValues: ["Server", "Web"],
186 },
187 "Server",
188 ),
189 ).toEqual({
190 customAnswer: "",
191 selectedOptionValues: ["Web"],
192 });
193 });
194
195 it("selects and removes options with the same label independently", () => {
196 const question = { ...nativeChoiceQuestion, multiSelect: true };
197 const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t");
198 const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second");
199
200 expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({
201 result: [" first\t", "second"],
202 });
203
204 const secondSelected = togglePendingUserInputOptionSelection(
205 question,
206 bothSelected,
207 " first\t",
208 );
209 expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({
210 result: ["second"],
211 });
212 });
213});
214
215describe("buildPendingUserInputAnswers", () => {
216 it("returns a canonical answer map for complete prompts", () => {
217 expect(
218 buildPendingUserInputAnswers(
219 [
220 singleSelectQuestion,
221 {
222 id: "compat",
223 header: "Compat",
224 question: "How strict should compatibility be?",
225 options: [
226 {
227 label: "Keep current envelope",
228 description: "Preserve current wire format",
229 },
230 ],
231 multiSelect: false,
232 },
233 ],
234 {
235 scope: {
236 selectedOptionValues: ["Orchestration-first"],
237 },
238 compat: {
239 customAnswer: "Keep the current envelope for one release window",
240 },
241 },
242 ),
243 ).toEqual({
244 scope: "Orchestration-first",
245 compat: "Keep the current envelope for one release window",
246 });
247 });
248
249 it("returns arrays for answered multi-select prompts", () => {
250 expect(
251 buildPendingUserInputAnswers([multiSelectQuestion], {
252 areas: {
253 selectedOptionValues: ["Server", "Web"],
254 },
255 }),
256 ).toEqual({
257 areas: ["Server", "Web"],
258 });
259 });
260
261 it("returns null when any question is unanswered", () => {
262 expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull();
263 });
264
265 it.each([" first\t", ""])("preserves the exact selected option value %j", (value) => {
266 const question = {
267 ...nativeChoiceQuestion,
268 options: [{ ...nativeChoiceQuestion.options[0], value }, nativeChoiceQuestion.options[1]],
269 };
270 const draft = togglePendingUserInputOptionSelection(question, undefined, value);
271
272 expect(buildPendingUserInputAnswers([question], { result: draft })).toEqual({ result: value });
273 expect(derivePendingUserInputProgress([question], { result: draft }, 0)).toMatchObject({
274 selectedOptionValues: [value],
275 answeredQuestionCount: 1,
276 canAdvance: true,
277 isComplete: true,
278 });
279 });
280});
281
282describe("pending user input question progress", () => {
283 const questions = [
284 singleSelectQuestion,
285 {
286 id: "compat",
287 header: "Compat",
288 question: "How strict should compatibility be?",
289 options: [
290 {
291 label: "Keep current envelope",
292 description: "Preserve current wire format",
293 },
294 ],
295 multiSelect: false,
296 },
297 ] as const;
298
299 it("counts only answered questions", () => {
300 expect(
301 countAnsweredPendingUserInputQuestions(questions, {
302 scope: {
303 selectedOptionValues: ["Orchestration-first"],
304 },
305 }),
306 ).toBe(1);
307 });
308
309 it("finds the first unanswered question", () => {
310 expect(
311 findFirstUnansweredPendingUserInputQuestionIndex(questions, {
312 scope: {
313 selectedOptionValues: ["Orchestration-first"],
314 },
315 }),
316 ).toBe(1);
317 });
318
319 it("returns the last question index when all answers are complete", () => {
320 expect(
321 findFirstUnansweredPendingUserInputQuestionIndex(questions, {
322 scope: {
323 selectedOptionValues: ["Orchestration-first"],
324 },
325 compat: {
326 customAnswer: "Keep it for one release window",
327 },
328 }),
329 ).toBe(1);
330 });
331
332 it("derives the active question and advancement state", () => {
333 expect(
334 derivePendingUserInputProgress(
335 questions,
336 {
337 scope: {
338 selectedOptionValues: ["Orchestration-first"],
339 },
340 },
341 0,
342 ),
343 ).toMatchObject({
344 questionIndex: 0,
345 activeQuestion: questions[0],
346 selectedOptionValues: ["Orchestration-first"],
347 customAnswer: "",
348 resolvedAnswer: "Orchestration-first",
349 answeredQuestionCount: 1,
350 isLastQuestion: false,
351 isComplete: false,
352 canAdvance: true,
353 });
354 });
355
356 it("treats multi-select questions as answered when they have selected options", () => {
357 expect(
358 derivePendingUserInputProgress(
359 [multiSelectQuestion],
360 {
361 areas: {
362 selectedOptionValues: ["Server", "Web"],
363 },
364 },
365 0,
366 ),
367 ).toMatchObject({
368 selectedOptionValues: ["Server", "Web"],
369 resolvedAnswer: ["Server", "Web"],
370 canAdvance: true,
371 isComplete: true,
372 });
373 });
374
375 it("requires an option when custom answers are disabled", () => {
376 const drafts = { result: { customAnswer: "Use another result" } };
377
378 expect(buildPendingUserInputAnswers([nativeChoiceQuestion], drafts)).toBeNull();
379 expect(derivePendingUserInputProgress([nativeChoiceQuestion], drafts, 0)).toMatchObject({
380 customAnswer: "",
381 usingCustomAnswer: false,
382 resolvedAnswer: null,
383 answeredQuestionCount: 0,
384 canAdvance: false,
385 isComplete: false,
386 });
387 });
388});
389
390it("accepts attachment-only answers after every upload finishes", () => {
391 const questions = [
392 { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false },
393 ];
394 expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({
395 spec: "",
396 });
397 expect(
398 buildPendingUserInputAnswers(questions, {
399 spec: { attachmentCount: 1, attachmentsBlocked: true },
400 }),
401 ).toBeNull();
402 expect(
403 buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], {
404 spec: { attachmentCount: 1 },
405 }),
406 ).toBeNull();
407});
408
409describe("carryDisplacedCustomAnswerIntoPrompt", () => {
410 it("keeps the thread draft when nothing was typed into the answer", () => {
411 expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft");
412 expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft");
413 });
414
415 it("moves the typed answer into an empty thread draft", () => {
416 expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe(
417 "also rename the flag",
418 );
419 });
420
421 it("appends the typed answer after an existing thread draft", () => {
422 expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe(
423 "first half\n\nsecond half",
424 );
425 });
426});