apply.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  5. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  6. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  7. import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
  9. import { ModelsSection } from '../src/client/ModelsSection.tsx'
  10. import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
  11. // The service reads its initial locale from the browser; these specs assert
  12. // the shipped Chinese copy, so they state the browser they assume.
  13. usePinnedBrowserLanguages('zh-CN')
  14. async function bench() {
  15. const ctx = new Context()
  16. await ctx.plugin(SlotsService).await()
  17. const locale = new LocaleService(ctx)
  18. ctx.provide('locale', locale)
  19. // The plugins inject `remote`; forwarded events reach them through the
  20. // same `$dispatch` handoff the connection sink makes.
  21. new TestRemote(ctx)
  22. // The apply path only captures the wire face; no call leaves this fake
  23. // until a section actually loads.
  24. ctx.provide('connection', { api: {} } as never)
  25. return { ctx, slots: ctx.get('slots') as SlotsService, locale }
  26. }
  27. function declare(slots: SlotsService): () => void {
  28. return slots.register(
  29. {
  30. name: 'root',
  31. children: {
  32. 'settings.section': { kind: 'list', scope: 'root' },
  33. 'settings.onboarding': { kind: 'list', scope: 'root' },
  34. },
  35. } as never,
  36. () => null,
  37. )
  38. }
  39. describe('ui-models apply', () => {
  40. it('declares the services it uses', () => {
  41. expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
  42. })
  43. it('registers the models nav entry for declarations before or after apply', async () => {
  44. const before = await bench()
  45. declare(before.slots)
  46. await before.ctx.plugin({ inject: [...inject], apply }).await()
  47. const entry = before.slots.entries('settings.section')[0]!
  48. expect(entry.component).toBe(ModelsSection)
  49. expect(entry.options).toMatchObject({ id: 'models', order: 10 })
  50. // The nav label is a locale-following thunk; owners resolve at read time.
  51. expect(resolveSlotLabel(entry.options.label)).toBe('模型')
  52. const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
  53. expect(injected.t('nav')).toBe('模型')
  54. expect(injected.t('deleteTitle')).toBe('删除 {provider}?')
  55. expect(typeof injected.controller.load).toBe('function')
  56. expect(typeof injected.useSnapshot).toBe('function')
  57. expect(injected.api).toBeDefined()
  58. const onboarding = before.slots.entries('settings.onboarding')[0]!
  59. expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
  60. expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
  61. const after = await bench()
  62. await after.ctx.plugin({ inject: [...inject], apply }).await()
  63. expect(after.slots.entries('settings.section')).toHaveLength(0)
  64. expect(after.slots.entries('settings.onboarding')).toHaveLength(0)
  65. declare(after.slots)
  66. await Promise.resolve()
  67. expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
  68. expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
  69. // The self-inflicted ledger notifications hit the duplicate guard.
  70. expect(after.slots.entries('settings.section')).toHaveLength(1)
  71. })
  72. it('the label thunk follows the active locale without re-registration', async () => {
  73. const b = await bench()
  74. declare(b.slots)
  75. await b.ctx.plugin({ inject: [...inject], apply }).await()
  76. b.locale.setLocale('en')
  77. expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
  78. const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
  79. expect(injected().t('deleteTitle')).toBe('Delete {provider}?')
  80. b.locale.setLocale('zh')
  81. expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
  82. expect(injected().t('deleteTitle')).toBe('删除 {provider}?')
  83. })
  84. it('locale change while the slot is undeclared stays a no-op', async () => {
  85. const b = await bench()
  86. await b.ctx.plugin({ inject: [...inject], apply }).await()
  87. b.locale.setLocale('en')
  88. expect(b.slots.entries('settings.section')).toHaveLength(0)
  89. b.locale.setLocale('zh')
  90. })
  91. it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
  92. const b = await bench()
  93. const redeclare = declare(b.slots)
  94. await b.ctx.plugin({ inject: [...inject], apply }).await()
  95. expect(b.slots.entries('settings.section')).toHaveLength(1)
  96. // Declarer unload: the cascade removes our entry while our local
  97. // disposer variable goes stale.
  98. redeclare()
  99. expect(b.slots.entries('settings.section')).toHaveLength(0)
  100. expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
  101. declare(b.slots)
  102. await Promise.resolve()
  103. expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
  104. expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
  105. // The locale path also recovers through the same ledger re-check.
  106. b.locale.setLocale('en')
  107. expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
  108. b.locale.setLocale('zh')
  109. })
  110. it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => {
  111. const b = await bench()
  112. declare(b.slots)
  113. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  114. await fiber.await()
  115. expect(b.locale.bind('settings.models')('nav')).toBe('模型')
  116. await fiber.dispose()
  117. expect(b.slots.entries('settings.section')).toHaveLength(0)
  118. expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
  119. // The (ns, locale) seats are free again — the dictionary disposers ran.
  120. expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow()
  121. expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
  122. })
  123. })
  124. describe('pushed invalidations', () => {
  125. it('ignores invalidations before the page ever loaded', async () => {
  126. const b = await bench()
  127. declare(b.slots)
  128. await b.ctx.plugin({ inject: [...inject], apply }).await()
  129. // The fake wire face has no methods: a fetch attempt would throw.
  130. b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1])
  131. b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY'])
  132. b.ctx.remote.$dispatch('llm/adapters-updated', [])
  133. b.ctx.emit('connection/reset')
  134. })
  135. it('refreshes a loaded page and skips an idle one', () => {
  136. const loads: number[] = []
  137. const controller = {
  138. store: { getSnapshot: () => ({ status: 'ready' }) },
  139. load: () => { loads.push(1); return Promise.resolve() },
  140. }
  141. refreshIfLoaded(controller as unknown as import('../src/client/store.ts').ModelsSettingsStore)
  142. expect(loads).toHaveLength(1)
  143. const idle = {
  144. store: { getSnapshot: () => ({ status: 'idle' }) },
  145. load: () => { loads.push(2); return Promise.resolve() },
  146. }
  147. refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
  148. expect(loads).toHaveLength(1)
  149. })
  150. it('routes pushed credential invalidation into the shared onboarding join', async () => {
  151. const b = await bench()
  152. declare(b.slots)
  153. await b.ctx.plugin({ inject: [...inject], apply }).await()
  154. const injected = (
  155. b.slots.entries('settings.onboarding')[0]!.inject as unknown as
  156. () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
  157. )()
  158. injected.controller.store.update((state) => { state.status = 'ready' })
  159. const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
  160. b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
  161. expect(load).toHaveBeenCalledTimes(1)
  162. })
  163. })