connect.resolver.test.ts 19 KB

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