apply.spec.ts 7.7 KB

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