Skip to content

Commit b3a8a02

Browse files
committed
multi select
1 parent bb3eb00 commit b3a8a02

File tree

11 files changed

+572
-58
lines changed

11 files changed

+572
-58
lines changed
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
/**
2+
* Unit tests for multi-select functionality
3+
* Testing multi-select support across types, navigation, and state management
4+
*/
5+
6+
import { describe, it, expect } from 'bun:test'
7+
import {
8+
isMultiSelectAnswer,
9+
isSingleSelectAnswer,
10+
isQuestionAnswered,
11+
areAllQuestionsAnswered,
12+
} from '../types'
13+
import { shouldAutoAdvance } from '../utils/navigation-handlers'
14+
import type { AskUserQuestion } from '../../../state/chat-store'
15+
16+
describe('Multi-Select Type Guards', () => {
17+
describe('isMultiSelectAnswer', () => {
18+
it('returns true for empty array', () => {
19+
expect(isMultiSelectAnswer([])).toBe(true)
20+
})
21+
22+
it('returns true for array with single element', () => {
23+
expect(isMultiSelectAnswer([0])).toBe(true)
24+
})
25+
26+
it('returns true for array with multiple elements', () => {
27+
expect(isMultiSelectAnswer([0, 1, 2])).toBe(true)
28+
})
29+
30+
it('returns false for number (single-select)', () => {
31+
expect(isMultiSelectAnswer(0)).toBe(false)
32+
expect(isMultiSelectAnswer(-1)).toBe(false)
33+
expect(isMultiSelectAnswer(5)).toBe(false)
34+
})
35+
})
36+
37+
describe('isSingleSelectAnswer', () => {
38+
it('returns true for positive numbers', () => {
39+
expect(isSingleSelectAnswer(0)).toBe(true)
40+
expect(isSingleSelectAnswer(1)).toBe(true)
41+
expect(isSingleSelectAnswer(99)).toBe(true)
42+
})
43+
44+
it('returns true for -1 (unanswered)', () => {
45+
expect(isSingleSelectAnswer(-1)).toBe(true)
46+
})
47+
48+
it('returns false for arrays (multi-select)', () => {
49+
expect(isSingleSelectAnswer([])).toBe(false)
50+
expect(isSingleSelectAnswer([0])).toBe(false)
51+
expect(isSingleSelectAnswer([0, 1, 2])).toBe(false)
52+
})
53+
})
54+
})
55+
56+
describe('Multi-Select Answer Validation', () => {
57+
describe('isQuestionAnswered - multi-select mode', () => {
58+
it('returns true when one option selected', () => {
59+
expect(isQuestionAnswered([0], '')).toBe(true)
60+
expect(isQuestionAnswered([2], '')).toBe(true)
61+
})
62+
63+
it('returns true when multiple options selected', () => {
64+
expect(isQuestionAnswered([0, 1], '')).toBe(true)
65+
expect(isQuestionAnswered([0, 1, 2], '')).toBe(true)
66+
expect(isQuestionAnswered([1, 3, 5], '')).toBe(true)
67+
})
68+
69+
it('returns false when no options selected (empty array)', () => {
70+
expect(isQuestionAnswered([], '')).toBe(false)
71+
})
72+
73+
it('returns true when other text provided (even with empty array)', () => {
74+
expect(isQuestionAnswered([], 'custom answer')).toBe(true)
75+
expect(isQuestionAnswered([], 'some text')).toBe(true)
76+
})
77+
78+
it('returns false when empty array and no text', () => {
79+
expect(isQuestionAnswered([], '')).toBe(false)
80+
expect(isQuestionAnswered([], ' ')).toBe(false)
81+
})
82+
83+
it('ignores whitespace-only text', () => {
84+
expect(isQuestionAnswered([], ' ')).toBe(false)
85+
expect(isQuestionAnswered([], '\t\n ')).toBe(false)
86+
})
87+
})
88+
89+
describe('areAllQuestionsAnswered - mixed single and multi-select', () => {
90+
it('returns true when all single-select questions answered', () => {
91+
const answers = [0, 1, 2] // All single-select
92+
const otherTexts = ['', '', '']
93+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(true)
94+
})
95+
96+
it('returns true when all multi-select questions answered', () => {
97+
const answers = [[0, 1], [2], [0, 2, 3]] // All multi-select
98+
const otherTexts = ['', '', '']
99+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(true)
100+
})
101+
102+
it('returns true with mix of single and multi-select', () => {
103+
const answers: (number | number[])[] = [0, [1, 2], 2, [0]]
104+
const otherTexts = ['', '', '', '']
105+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(true)
106+
})
107+
108+
it('returns false when any multi-select question unanswered', () => {
109+
const answers: (number | number[])[] = [[0, 1], [], [2]] // Second is empty
110+
const otherTexts = ['', '', '']
111+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(false)
112+
})
113+
114+
it('returns false when any single-select question unanswered', () => {
115+
const answers: (number | number[])[] = [0, -1, [1, 2]] // Second is -1
116+
const otherTexts = ['', '', '']
117+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(false)
118+
})
119+
120+
it('returns true with mix of options and other text', () => {
121+
const answers: (number | number[])[] = [[0, 1], -1, [], 2]
122+
const otherTexts = ['', 'custom', 'another', '']
123+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(true)
124+
})
125+
126+
it('handles all questions with other text', () => {
127+
const answers: (number | number[])[] = [-1, [], -1]
128+
const otherTexts = ['text1', 'text2', 'text3']
129+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(true)
130+
})
131+
132+
it('returns false when mix has unanswered questions', () => {
133+
const answers: (number | number[])[] = [0, [], -1, [1]]
134+
const otherTexts = ['', '', '', ''] // All empty
135+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(false)
136+
})
137+
})
138+
})
139+
140+
describe('Multi-Select Auto-Advance Behavior', () => {
141+
it('disables auto-advance for multi-select questions', () => {
142+
const multiSelectQuestion: AskUserQuestion = {
143+
question: 'Select all that apply',
144+
options: ['Option 1', 'Option 2', 'Option 3'],
145+
multiSelect: true,
146+
}
147+
expect(shouldAutoAdvance(multiSelectQuestion)).toBe(false)
148+
})
149+
150+
it('enables auto-advance for single-select questions', () => {
151+
const singleSelectQuestion: AskUserQuestion = {
152+
question: 'Choose one',
153+
options: ['Option 1', 'Option 2'],
154+
}
155+
expect(shouldAutoAdvance(singleSelectQuestion)).toBe(true)
156+
})
157+
158+
it('enables auto-advance when multiSelect is explicitly false', () => {
159+
const singleSelectQuestion: AskUserQuestion = {
160+
question: 'Choose one',
161+
options: ['Option 1', 'Option 2'],
162+
multiSelect: false,
163+
}
164+
expect(shouldAutoAdvance(singleSelectQuestion)).toBe(true)
165+
})
166+
167+
it('handles questions with option objects', () => {
168+
const multiSelectQuestion: AskUserQuestion = {
169+
question: 'Select features',
170+
options: [
171+
{ label: 'Feature A', description: 'Description A' },
172+
{ label: 'Feature B', description: 'Description B' },
173+
],
174+
multiSelect: true,
175+
}
176+
expect(shouldAutoAdvance(multiSelectQuestion)).toBe(false)
177+
})
178+
})
179+
180+
describe('Multi-Select Answer Formatting', () => {
181+
it('properly formats single option selection', () => {
182+
const answer = [0]
183+
expect(answer.length).toBe(1)
184+
expect(answer).toContain(0)
185+
})
186+
187+
it('properly formats multiple option selections', () => {
188+
const answer = [0, 2, 4]
189+
expect(answer.length).toBe(3)
190+
expect(answer).toContain(0)
191+
expect(answer).toContain(2)
192+
expect(answer).toContain(4)
193+
})
194+
195+
it('handles option toggling (adding)', () => {
196+
let answer = [0, 1]
197+
const newOption = 2
198+
199+
if (!answer.includes(newOption)) {
200+
answer = [...answer, newOption]
201+
}
202+
203+
expect(answer).toEqual([0, 1, 2])
204+
})
205+
206+
it('handles option toggling (removing)', () => {
207+
let answer = [0, 1, 2]
208+
const optionToRemove = 1
209+
210+
if (answer.includes(optionToRemove)) {
211+
answer = answer.filter((i) => i !== optionToRemove)
212+
}
213+
214+
expect(answer).toEqual([0, 2])
215+
})
216+
217+
it('handles toggling same option twice (add then remove)', () => {
218+
let answer: number[] = []
219+
220+
// Add option 1
221+
answer = [...answer, 1]
222+
expect(answer).toEqual([1])
223+
224+
// Remove option 1
225+
answer = answer.filter((i) => i !== 1)
226+
expect(answer).toEqual([])
227+
})
228+
229+
it('maintains order when toggling multiple options', () => {
230+
let answer: number[] = []
231+
232+
// Add in order: 2, 0, 3, 1
233+
answer = [...answer, 2]
234+
answer = [...answer, 0]
235+
answer = [...answer, 3]
236+
answer = [...answer, 1]
237+
238+
expect(answer).toEqual([2, 0, 3, 1])
239+
240+
// Remove 0
241+
answer = answer.filter((i) => i !== 0)
242+
expect(answer).toEqual([2, 3, 1])
243+
})
244+
})
245+
246+
describe('Multi-Select Edge Cases', () => {
247+
it('handles empty options array', () => {
248+
const answers: (number | number[])[] = [[]]
249+
const otherTexts = ['']
250+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(false)
251+
})
252+
253+
it('handles very large selection (all options)', () => {
254+
const answer = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
255+
expect(answer.length).toBe(10)
256+
expect(isQuestionAnswered(answer, '')).toBe(true)
257+
})
258+
259+
it('handles single-element array vs number distinction', () => {
260+
const multiSelectAnswer = [0] // Array with one element
261+
const singleSelectAnswer = 0 // Just a number
262+
263+
expect(isMultiSelectAnswer(multiSelectAnswer)).toBe(true)
264+
expect(isSingleSelectAnswer(multiSelectAnswer)).toBe(false)
265+
266+
expect(isMultiSelectAnswer(singleSelectAnswer)).toBe(false)
267+
expect(isSingleSelectAnswer(singleSelectAnswer)).toBe(true)
268+
})
269+
270+
it('validates mixed question types with all edge cases', () => {
271+
const answers: (number | number[])[] = [
272+
-1, // Unanswered single-select
273+
[], // Unanswered multi-select
274+
0, // Answered single-select
275+
[0, 1, 2], // Answered multi-select
276+
]
277+
const otherTexts = [
278+
'custom', // Covers first unanswered
279+
'', // Does not cover second
280+
'', // Not needed
281+
'', // Not needed
282+
]
283+
284+
// Should be false because answers[1] is empty array with no text
285+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(false)
286+
})
287+
288+
it('validates all answered with mixed types', () => {
289+
const answers: (number | number[])[] = [
290+
0, // Answered single-select
291+
[0], // Answered multi-select (one option)
292+
[0, 1, 2], // Answered multi-select (multiple options)
293+
-1, // Unanswered but has text
294+
]
295+
const otherTexts = ['', '', '', 'custom answer']
296+
297+
expect(areAllQuestionsAnswered(answers, otherTexts)).toBe(true)
298+
})
299+
})
300+
301+
describe('Multi-Select Question Properties', () => {
302+
it('handles question with header', () => {
303+
const question: AskUserQuestion = {
304+
question: 'Which features do you want?',
305+
header: 'Features',
306+
options: ['Feature 1', 'Feature 2', 'Feature 3'],
307+
multiSelect: true,
308+
}
309+
310+
expect(question.header).toBe('Features')
311+
expect(question.multiSelect).toBe(true)
312+
expect(shouldAutoAdvance(question)).toBe(false)
313+
})
314+
315+
it('handles question with option descriptions', () => {
316+
const question: AskUserQuestion = {
317+
question: 'Select authentication methods',
318+
header: 'Auth',
319+
options: [
320+
{ label: 'JWT', description: 'Stateless tokens' },
321+
{ label: 'Sessions', description: 'Server-side sessions' },
322+
{ label: 'OAuth', description: 'Third-party auth' },
323+
],
324+
multiSelect: true,
325+
}
326+
327+
expect(question.options.length).toBe(3)
328+
expect(typeof question.options[0]).toBe('object')
329+
expect((question.options[0] as any).label).toBe('JWT')
330+
expect((question.options[0] as any).description).toBe('Stateless tokens')
331+
})
332+
333+
it('handles question with validation rules', () => {
334+
const question: AskUserQuestion = {
335+
question: 'Select options or enter custom',
336+
options: ['Option 1', 'Option 2'],
337+
multiSelect: true,
338+
validation: {
339+
minLength: 3,
340+
maxLength: 50,
341+
pattern: '^[a-zA-Z0-9 ]+$',
342+
patternError: 'Only alphanumeric characters allowed',
343+
},
344+
}
345+
346+
expect(question.validation).toBeDefined()
347+
expect(question.validation?.minLength).toBe(3)
348+
expect(question.validation?.maxLength).toBe(50)
349+
expect(question.validation?.pattern).toBe('^[a-zA-Z0-9 ]+$')
350+
})
351+
352+
it('handles backwards compatibility (no multiSelect flag)', () => {
353+
const question: AskUserQuestion = {
354+
question: 'Choose one',
355+
options: ['Option 1', 'Option 2'],
356+
// No multiSelect property - should default to false
357+
}
358+
359+
// Should enable auto-advance (single-select behavior)
360+
expect(shouldAutoAdvance(question)).toBe(true)
361+
})
362+
})

0 commit comments

Comments
 (0)