apply.spec.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. /** ui-theme apply wiring: service provision, settings dictionaries riding the
  2. * locale service, declaration-aware Appearance row registration, snapshot
  3. * projection into the row store, and HMR collapse recovery. */
  4. import { Context } from 'cordis'
  5. import { describe, expect, it } from 'vitest'
  6. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  7. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  8. import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
  9. import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
  10. import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
  11. import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
  12. const SLOT = 'settings.general.item'
  13. async function bench() {
  14. const ctx = new Context()
  15. await ctx.plugin(SlotsService).await()
  16. const locale = new LocaleService(ctx)
  17. ctx.provide('locale', locale)
  18. return { ctx, slots: ctx.get('slots') as SlotsService, locale }
  19. }
  20. /** Stand in for the settings shell: declare the General item slot from root. */
  21. function declareItems(slots: SlotsService): () => void {
  22. return slots.register(
  23. { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
  24. () => null,
  25. )
  26. }
  27. /** Mirror the framework's inject choreography: bake a real instance from the
  28. * declared handle and hand its actions to the entry's inject factory. */
  29. function faceOf(slots: SlotsService) {
  30. const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)!
  31. const handle = entry.store as ReturnType<typeof createAppearanceRowStore>
  32. const instance = handle.create()
  33. const face = (entry.inject as unknown as (a: typeof instance.actions) => AppearanceRowInjected)(instance.actions)
  34. return { entry, instance, face }
  35. }
  36. describe('ui-theme apply', () => {
  37. it('declares the slot and locale services', () => {
  38. expect(inject).toEqual(['slots', 'locale'])
  39. })
  40. it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => {
  41. const before = await bench()
  42. declareItems(before.slots)
  43. await before.ctx.plugin({ inject: [...inject], apply }).await()
  44. expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观')
  45. before.locale.setLocale('en')
  46. expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance')
  47. const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)!
  48. expect(entry.options).toMatchObject({ id: 'appearance', order: 10 })
  49. const after = await bench()
  50. const fiber = after.ctx.plugin({ inject: [...inject], apply })
  51. await fiber.await()
  52. expect(after.slots.entries(SLOT)).toHaveLength(0)
  53. declareItems(after.slots)
  54. await Promise.resolve()
  55. expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true)
  56. })
  57. it('projects service snapshots into the row store and routes face writes back', async () => {
  58. const b = await bench()
  59. declareItems(b.slots)
  60. await b.ctx.plugin({ inject: [...inject], apply }).await()
  61. const theme = b.ctx.get('theme') as ThemeService
  62. // An event ahead of any inject hits the unbound-actions arm.
  63. theme.setTheme('dark')
  64. const { instance, face } = faceOf(b.slots)
  65. // The inject-time re-sync sealed the init window: the mirror is current.
  66. expect(instance.getSnapshot().preference).toBe('dark')
  67. expect(face.t('appearance.dark')).toBe('深色')
  68. face.setTheme('system')
  69. expect(theme.getTheme().preference).toBe('system')
  70. expect(instance.getSnapshot().preference).toBe('system')
  71. })
  72. it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
  73. const b = await bench()
  74. const host = declareItems(b.slots)
  75. await b.ctx.plugin({ inject: [...inject], apply }).await()
  76. expect(b.slots.entries(SLOT)).toHaveLength(1)
  77. // Collapse: the declarer dies, the cascade removes our entry while the
  78. // apply closure still holds its (now stale) disposer.
  79. host()
  80. expect(b.slots.entries(SLOT)).toHaveLength(0)
  81. declareItems(b.slots)
  82. await Promise.resolve()
  83. expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true)
  84. })
  85. it('teardown removes the row and the dictionaries; teardown without a declaration is quiet', async () => {
  86. const b = await bench()
  87. declareItems(b.slots)
  88. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  89. await fiber.await()
  90. expect(b.slots.entries(SLOT)).toHaveLength(1)
  91. await fiber.dispose()
  92. expect(b.slots.entries(SLOT)).toHaveLength(0)
  93. // Dictionary disposal: translation falls back to the bare key.
  94. expect(b.locale.bind(SETTINGS_NS)('appearance.title')).toBe('appearance.title')
  95. // Never-declared bench: the effect disposer's dispose arm stays undefined.
  96. const quiet = await bench()
  97. const f2 = quiet.ctx.plugin({ inject: [...inject], apply })
  98. await f2.await()
  99. await f2.dispose()
  100. expect(quiet.slots.entries(SLOT)).toHaveLength(0)
  101. })
  102. })