apply.spec.ts 7.9 KB

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