connect.resolver.test.ts624 lines · main
1import { describe, expect, test } from 'vitest'
2
3import {
4 getActiveFields,
5 getDefaultState,
6 resetDependentFields,
7 resolveConditional,
8 resolveSteps,
9} from './connect.resolver'
10import type { ConditionalValue, ConnectSchema, ConnectState, StepTree } from './Connect.types'
11
12// ============================================================================
13// resolveConditional Tests
14// ============================================================================
15
16describe('connect.resolver:resolveConditional', () => {
17 test('should return primitive values directly', () => {
18 expect(resolveConditional('hello', { mode: 'framework' })).toBe('hello')
19 expect(resolveConditional(42, { mode: 'framework' })).toBe(42)
20 expect(resolveConditional(null, { mode: 'framework' })).toBe(null)
21 expect(resolveConditional(true, { mode: 'framework' })).toBe(true)
22 })
23
24 test('should return arrays directly', () => {
25 const arr = ['a', 'b', 'c']
26 expect(resolveConditional(arr, { mode: 'framework' })).toBe(arr)
27 })
28
29 test('should resolve single-level conditional based on mode', () => {
30 const conditional: ConditionalValue<string> = {
31 framework: 'Framework Content',
32 direct: 'Direct Content',
33 DEFAULT: 'Default Content',
34 }
35
36 expect(resolveConditional(conditional, { mode: 'framework' })).toBe('Framework Content')
37 expect(resolveConditional(conditional, { mode: 'direct' })).toBe('Direct Content')
38 expect(resolveConditional(conditional, { mode: 'orm' })).toBe('Default Content')
39 })
40
41 test('should resolve nested conditionals (mode -> framework)', () => {
42 const conditional: ConditionalValue<string> = {
43 framework: {
44 nextjs: 'Next.js Content',
45 react: 'React Content',
46 DEFAULT: 'Generic Framework',
47 },
48 direct: 'Direct Content',
49 }
50
51 expect(resolveConditional(conditional, { mode: 'framework', framework: 'nextjs' })).toBe(
52 'Next.js Content'
53 )
54 expect(resolveConditional(conditional, { mode: 'framework', framework: 'react' })).toBe(
55 'React Content'
56 )
57 expect(resolveConditional(conditional, { mode: 'framework', framework: 'vue' })).toBe(
58 'Generic Framework'
59 )
60 expect(resolveConditional(conditional, { mode: 'direct' })).toBe('Direct Content')
61 })
62
63 test('should resolve deeply nested conditionals (mode -> framework -> variant)', () => {
64 const conditional: ConditionalValue<string> = {
65 framework: {
66 nextjs: {
67 app: 'App Router Content',
68 pages: 'Pages Router Content',
69 DEFAULT: 'Generic Next.js',
70 },
71 DEFAULT: 'Generic Framework',
72 },
73 }
74
75 expect(
76 resolveConditional(conditional, {
77 mode: 'framework',
78 framework: 'nextjs',
79 frameworkVariant: 'app',
80 })
81 ).toBe('App Router Content')
82 expect(
83 resolveConditional(conditional, {
84 mode: 'framework',
85 framework: 'nextjs',
86 frameworkVariant: 'pages',
87 })
88 ).toBe('Pages Router Content')
89 expect(
90 resolveConditional(conditional, {
91 mode: 'framework',
92 framework: 'nextjs',
93 frameworkVariant: 'unknown',
94 })
95 ).toBe('Generic Next.js')
96 })
97
98 test('should resolve MCP client-specific content', () => {
99 const conditional: ConditionalValue<string> = {
100 mcp: {
101 cursor: 'Cursor Config',
102 codex: 'Codex Config',
103 'claude-code': 'Claude Code Config',
104 DEFAULT: 'Generic MCP',
105 },
106 }
107
108 expect(resolveConditional(conditional, { mode: 'mcp', mcpClient: 'cursor' })).toBe(
109 'Cursor Config'
110 )
111 expect(resolveConditional(conditional, { mode: 'mcp', mcpClient: 'codex' })).toBe(
112 'Codex Config'
113 )
114 expect(resolveConditional(conditional, { mode: 'mcp', mcpClient: 'claude-code' })).toBe(
115 'Claude Code Config'
116 )
117 })
118
119 test('should return undefined when no match and no DEFAULT', () => {
120 const conditional: ConditionalValue<string> = {
121 framework: 'Framework',
122 direct: 'Direct',
123 }
124
125 expect(resolveConditional(conditional, { mode: 'orm' })).toBe(undefined)
126 })
127
128 test('should handle frameworkUi boolean state (as string "true"/"false")', () => {
129 const conditional: ConditionalValue<string> = {
130 framework: {
131 nextjs: {
132 true: 'Shadcn Steps',
133 DEFAULT: 'Regular Steps',
134 },
135 },
136 }
137
138 // When frameworkUi is true (boolean), it gets converted to string "true"
139 expect(
140 resolveConditional(conditional, { mode: 'framework', framework: 'nextjs', frameworkUi: true })
141 ).toBe('Shadcn Steps')
142 expect(
143 resolveConditional(conditional, {
144 mode: 'framework',
145 framework: 'nextjs',
146 frameworkUi: false,
147 })
148 ).toBe('Regular Steps')
149 })
150})
151
152// ============================================================================
153// resolveSteps Tests
154// ============================================================================
155
156describe('connect.resolver:resolveSteps', () => {
157 const createMockSchema = (steps: StepTree): ConnectSchema => ({
158 modes: [
159 { id: 'framework', label: 'Framework', description: '', fields: [] },
160 { id: 'direct', label: 'Direct', description: '', fields: [] },
161 ],
162 fields: {},
163 steps,
164 })
165
166 test('should resolve steps array for framework mode', () => {
167 const schema = createMockSchema({
168 mode: {
169 framework: [
170 { id: 'step1', title: 'Install', description: 'Install pkg', content: 'install-content' },
171 { id: 'step2', title: 'Configure', description: 'Configure', content: 'config-content' },
172 ],
173 direct: [
174 {
175 id: 'connection',
176 title: 'Connection',
177 description: 'Connect',
178 content: 'direct-content',
179 },
180 ],
181 },
182 })
183
184 const steps = resolveSteps(schema, { mode: 'framework' })
185 expect(steps).toHaveLength(2)
186 expect(steps[0].id).toBe('step1')
187 expect(steps[0].title).toBe('Install')
188 expect(steps[1].id).toBe('step2')
189 })
190
191 test('should resolve steps for direct mode', () => {
192 const schema = createMockSchema({
193 mode: {
194 framework: [{ id: 'step1', title: 'Install', description: 'Install', content: 'install' }],
195 direct: [
196 { id: 'connection', title: 'Connection', description: 'Connect', content: 'direct' },
197 ],
198 },
199 })
200
201 const steps = resolveSteps(schema, { mode: 'direct' })
202 expect(steps).toHaveLength(1)
203 expect(steps[0].id).toBe('connection')
204 })
205
206 test('should filter out steps with empty content', () => {
207 const schema = createMockSchema({
208 mode: {
209 framework: [
210 { id: 'step1', title: 'Valid', description: 'Valid step', content: 'valid-content' },
211 { id: 'step2', title: 'Empty', description: 'Empty step', content: '' },
212 { id: 'step3', title: 'Null', description: 'Null step', content: null },
213 ],
214 },
215 })
216
217 const steps = resolveSteps(schema, { mode: 'framework' })
218 expect(steps).toHaveLength(1)
219 expect(steps[0].id).toBe('step1')
220 })
221
222 test('should resolve conditional step content based on state', () => {
223 const schema = createMockSchema({
224 mode: {
225 framework: [
226 {
227 id: 'configure',
228 title: 'Configure',
229 description: 'Configure',
230 content: {
231 nextjs: 'nextjs-content',
232 react: 'react-content',
233 DEFAULT: 'generic-content',
234 },
235 },
236 ],
237 },
238 })
239
240 const nextjsSteps = resolveSteps(schema, { mode: 'framework', framework: 'nextjs' })
241 expect(nextjsSteps[0].content).toBe('nextjs-content')
242
243 const reactSteps = resolveSteps(schema, { mode: 'framework', framework: 'react' })
244 expect(reactSteps[0].content).toBe('react-content')
245
246 const vueSteps = resolveSteps(schema, { mode: 'framework', framework: 'vue' })
247 expect(vueSteps[0].content).toBe('generic-content')
248 })
249
250 test('should return empty array when no steps resolve', () => {
251 const schema = createMockSchema({
252 mode: {
253 framework: [{ id: 'step1', title: 'Step', description: 'Desc', content: '' }],
254 },
255 })
256
257 const steps = resolveSteps(schema, { mode: 'framework' })
258 expect(steps).toEqual([])
259 })
260
261 test('should return empty array when steps is not an array', () => {
262 const schema = createMockSchema({
263 mode: {
264 framework: {
265 framework: {},
266 },
267 },
268 } as any)
269
270 const steps = resolveSteps(schema, { mode: 'framework' })
271 expect(steps).toEqual([])
272 })
273
274 test('should append steps from multiple field conditions in order', () => {
275 const schema = createMockSchema({
276 mode: {
277 framework: {
278 framework: {
279 nextjs: [{ id: 'base', title: 'Base', description: 'Base step', content: 'base' }],
280 },
281 frameworkUi: {
282 true: [{ id: 'ui', title: 'UI', description: 'UI step', content: 'ui' }],
283 },
284 },
285 },
286 })
287
288 const steps = resolveSteps(schema, {
289 mode: 'framework',
290 framework: 'nextjs',
291 frameworkUi: true,
292 })
293 expect(steps.map((step) => step.id)).toEqual(['base', 'ui'])
294 })
295})
296
297// ============================================================================
298// getActiveFields Tests
299// ============================================================================
300
301describe('connect.resolver:getActiveFields', () => {
302 const createSchemaWithFields = (
303 modes: ConnectSchema['modes'],
304 fields: ConnectSchema['fields']
305 ): ConnectSchema => ({
306 modes,
307 fields,
308 steps: [],
309 })
310
311 test('should return fields for the current mode', () => {
312 const schema = createSchemaWithFields(
313 [
314 { id: 'framework', label: 'Framework', description: '', fields: ['framework', 'library'] },
315 { id: 'direct', label: 'Direct', description: '', fields: ['connectionType'] },
316 ],
317 {
318 framework: {
319 id: 'framework',
320 type: 'radio-grid',
321 label: 'Framework',
322 defaultValue: 'nextjs',
323 },
324 library: {
325 id: 'library',
326 type: 'select',
327 label: 'Library',
328 defaultValue: 'brivenjs',
329 },
330 connectionType: {
331 id: 'connectionType',
332 type: 'select',
333 label: 'Type',
334 defaultValue: 'uri',
335 },
336 }
337 )
338
339 const frameworkFields = getActiveFields(schema, { mode: 'framework' })
340 expect(frameworkFields).toHaveLength(2)
341 expect(frameworkFields.map((f) => f.id)).toEqual(['framework', 'library'])
342
343 const directFields = getActiveFields(schema, { mode: 'direct' })
344 expect(directFields).toHaveLength(1)
345 expect(directFields[0].id).toBe('connectionType')
346 })
347
348 test('should filter fields by dependsOn conditions', () => {
349 const schema = createSchemaWithFields(
350 [
351 {
352 id: 'framework',
353 label: 'Framework',
354 description: '',
355 fields: ['framework', 'frameworkVariant', 'frameworkUi'],
356 },
357 ],
358 {
359 framework: {
360 id: 'framework',
361 type: 'radio-grid',
362 label: 'Framework',
363 defaultValue: 'nextjs',
364 },
365 frameworkVariant: {
366 id: 'frameworkVariant',
367 type: 'select',
368 label: 'Variant',
369 dependsOn: { framework: ['nextjs', 'react'] },
370 },
371 frameworkUi: {
372 id: 'frameworkUi',
373 type: 'switch',
374 label: 'Shadcn',
375 dependsOn: { framework: ['nextjs', 'react'] },
376 },
377 }
378 )
379
380 // With nextjs - should show all fields
381 const nextjsFields = getActiveFields(schema, { mode: 'framework', framework: 'nextjs' })
382 expect(nextjsFields).toHaveLength(3)
383
384 // With vue - should hide frameworkVariant and frameworkUi
385 const vueFields = getActiveFields(schema, { mode: 'framework', framework: 'vue' })
386 expect(vueFields).toHaveLength(1)
387 expect(vueFields[0].id).toBe('framework')
388 })
389
390 test('should handle multiple dependsOn conditions', () => {
391 const schema = createSchemaWithFields(
392 [
393 {
394 id: 'direct',
395 label: 'Direct',
396 description: '',
397 fields: ['connectionMethod', 'useSharedPooler'],
398 },
399 ],
400 {
401 connectionMethod: {
402 id: 'connectionMethod',
403 type: 'radio-list',
404 label: 'Method',
405 defaultValue: 'direct',
406 },
407 useSharedPooler: {
408 id: 'useSharedPooler',
409 type: 'switch',
410 label: 'Use Shared Pooler',
411 dependsOn: { connectionMethod: ['transaction'] },
412 },
413 }
414 )
415
416 // Transaction mode - show shared pooler option
417 const transactionFields = getActiveFields(schema, {
418 mode: 'direct',
419 connectionMethod: 'transaction',
420 })
421 expect(transactionFields).toHaveLength(2)
422
423 // Direct mode - hide shared pooler option
424 const directFields = getActiveFields(schema, { mode: 'direct', connectionMethod: 'direct' })
425 expect(directFields).toHaveLength(1)
426 expect(directFields[0].id).toBe('connectionMethod')
427 })
428
429 test('should return empty array for invalid mode', () => {
430 const schema = createSchemaWithFields(
431 [{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }],
432 { framework: { id: 'framework', type: 'radio-grid', label: 'Framework' } }
433 )
434
435 const fields = getActiveFields(schema, { mode: 'invalid' as any })
436 expect(fields).toEqual([])
437 })
438
439 test('should include resolvedOptions for each field', () => {
440 const schema = createSchemaWithFields(
441 [{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }],
442 {
443 framework: {
444 id: 'framework',
445 type: 'radio-grid',
446 label: 'Framework',
447 options: { source: 'frameworks' }, // Source reference - resolved elsewhere
448 },
449 }
450 )
451
452 const fields = getActiveFields(schema, { mode: 'framework' })
453 expect(fields[0]).toHaveProperty('resolvedOptions')
454 // Source options are resolved by the hook, not the resolver
455 expect(fields[0].resolvedOptions).toEqual([])
456 })
457})
458
459// ============================================================================
460// getDefaultState Tests
461// ============================================================================
462
463describe('connect.resolver:getDefaultState', () => {
464 test('should return default state with first mode', () => {
465 const schema: ConnectSchema = {
466 modes: [
467 { id: 'framework', label: 'Framework', description: '', fields: [] },
468 { id: 'direct', label: 'Direct', description: '', fields: [] },
469 ],
470 fields: {},
471 steps: [],
472 }
473
474 const state = getDefaultState({ schema })
475 expect(state.mode).toBe('framework')
476 })
477
478 test('should include default values from fields', () => {
479 const schema: ConnectSchema = {
480 modes: [{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }],
481 fields: {
482 framework: {
483 id: 'framework',
484 type: 'radio-grid',
485 label: 'Framework',
486 defaultValue: 'nextjs',
487 },
488 library: {
489 id: 'library',
490 type: 'select',
491 label: 'Library',
492 defaultValue: 'brivenjs',
493 },
494 mcpReadonly: {
495 id: 'mcpReadonly',
496 type: 'switch',
497 label: 'Readonly',
498 defaultValue: false,
499 },
500 },
501 steps: [],
502 }
503
504 const state = getDefaultState({ schema })
505 expect(state.framework).toBe('nextjs')
506 expect(state.library).toBe('brivenjs')
507 expect(state.mcpReadonly).toBe(false)
508 })
509
510 test('should fallback to "direct" if no modes defined', () => {
511 const schema: ConnectSchema = {
512 modes: [],
513 fields: {},
514 steps: [],
515 }
516
517 const state = getDefaultState({ schema })
518 expect(state.mode).toBe('direct')
519 })
520})
521
522// ============================================================================
523// resetDependentFields Tests
524// ============================================================================
525
526describe('connect.resolver:resetDependentFields', () => {
527 const createSchemaForReset = (): ConnectSchema => ({
528 modes: [
529 {
530 id: 'framework',
531 label: 'Framework',
532 description: '',
533 fields: ['framework', 'frameworkVariant', 'frameworkUi'],
534 },
535 { id: 'direct', label: 'Direct', description: '', fields: ['connectionMethod'] },
536 ],
537 fields: {
538 framework: {
539 id: 'framework',
540 type: 'radio-grid',
541 label: 'Framework',
542 defaultValue: 'nextjs',
543 },
544 frameworkVariant: {
545 id: 'frameworkVariant',
546 type: 'select',
547 label: 'Variant',
548 dependsOn: { framework: ['nextjs', 'react'] },
549 },
550 frameworkUi: {
551 id: 'frameworkUi',
552 type: 'switch',
553 label: 'Shadcn',
554 dependsOn: { framework: ['nextjs', 'react'] },
555 },
556 connectionMethod: {
557 id: 'connectionMethod',
558 type: 'radio-list',
559 label: 'Method',
560 defaultValue: 'direct',
561 },
562 },
563 steps: [],
564 })
565
566 test('should reset dependent fields when dependency no longer satisfied', () => {
567 const schema = createSchemaForReset()
568 const state: ConnectState = {
569 mode: 'framework',
570 framework: 'vue', // Changed from nextjs to vue
571 frameworkVariant: 'app', // This should be reset
572 frameworkUi: true, // This should be reset
573 }
574
575 const newState = resetDependentFields(state, 'framework', schema)
576
577 expect(newState.frameworkVariant).toBeUndefined()
578 expect(newState.frameworkUi).toBeUndefined()
579 })
580
581 test('should keep dependent fields when dependency still satisfied', () => {
582 const schema = createSchemaForReset()
583 const state: ConnectState = {
584 mode: 'framework',
585 framework: 'react', // Still in the allowed list
586 frameworkVariant: 'vite',
587 frameworkUi: true,
588 }
589
590 const newState = resetDependentFields(state, 'framework', schema)
591
592 expect(newState.frameworkVariant).toBe('vite')
593 expect(newState.frameworkUi).toBe(true)
594 })
595
596 test('should handle mode changes', () => {
597 const schema = createSchemaForReset()
598 const state: ConnectState = {
599 mode: 'direct', // Changed mode
600 framework: 'nextjs',
601 frameworkVariant: 'app',
602 }
603
604 // Note: The current implementation of resetDependentFields for mode changes
605 // looks for fields not in the current mode, but the logic compares against previous mode
606 const newState = resetDependentFields(state, 'mode', schema)
607
608 // Mode-specific field reset logic is handled
609 expect(newState.mode).toBe('direct')
610 })
611
612 test('should not modify state for fields without dependencies', () => {
613 const schema = createSchemaForReset()
614 const state: ConnectState = {
615 mode: 'framework',
616 framework: 'nextjs',
617 }
618
619 const newState = resetDependentFields(state, 'framework', schema)
620
621 expect(newState.mode).toBe('framework')
622 expect(newState.framework).toBe('nextjs')
623 })
624})