apply.client.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. // @vitest-environment jsdom
  2. import { Context, type Fiber } from '@deepseek-ai/cordis'
  3. import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
  4. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  5. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  6. import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
  7. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  8. import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
  9. import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
  10. import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
  11. import type { MainPanelId } from '../src/client/service.ts'
  12. import type { createLayoutStore } from '../src/client/stores.ts'
  13. const owners = new Set<Fiber>()
  14. let originalRootStyle: string | null
  15. let originalBodyStyle: string | null
  16. let originalDarkTheme: string | null
  17. let originalThemeMetadata: Element[]
  18. beforeEach(() => {
  19. originalRootStyle = document.documentElement.getAttribute('style')
  20. originalBodyStyle = document.body.getAttribute('style')
  21. originalDarkTheme = document.body.getAttribute('data-ds-dark-theme')
  22. originalThemeMetadata = [...document.head.querySelectorAll('meta[name="theme-color"]')]
  23. originalThemeMetadata.forEach((node) => { node.remove() })
  24. vi.stubGlobal('innerWidth', 1920)
  25. })
  26. afterEach(async () => {
  27. try {
  28. for (const owner of owners) await owner.dispose()
  29. } finally {
  30. owners.clear()
  31. restoreAttribute(document.documentElement, 'style', originalRootStyle)
  32. restoreAttribute(document.body, 'style', originalBodyStyle)
  33. restoreAttribute(document.body, 'data-ds-dark-theme', originalDarkTheme)
  34. document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
  35. document.head.append(...originalThemeMetadata)
  36. vi.unstubAllGlobals()
  37. }
  38. })
  39. function restoreAttribute(element: Element, name: string, value: string | null): void {
  40. if (value === null) element.removeAttribute(name)
  41. else element.setAttribute(name, value)
  42. }
  43. async function bench() {
  44. const root = new Context()
  45. let ctx: Context | undefined
  46. const owner = root.plugin((ownedContext: Context) => { ctx = ownedContext })
  47. owners.add(owner)
  48. await owner.await()
  49. if (ctx === undefined) throw new Error('the fixture owner did not activate')
  50. const slotsFiber = ctx.plugin(SlotRegistry)
  51. // Theme registers its Appearance settings row and requires the connection
  52. // seam for persistence; model this bench as a remote, memory-only browser.
  53. ctx.provide('locale', new LocaleRuntime(ctx))
  54. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
  55. // ui-theme's Appearance row binds a durable scope through these two.
  56. ctx.provide('remote', { $on: () => () => {} } as never)
  57. ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
  58. await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
  59. await slotsFiber.await()
  60. const slots = ctx.get('slots') as SlotRegistry
  61. let host: SlotRendererHost | undefined
  62. slots.install({ renderRoot: (value) => { host = value; return null } })
  63. const rendererHost = (): SlotRendererHost => {
  64. slots.renderSlot('root', {})
  65. if (host === undefined) throw new Error('the root renderer did not receive its host')
  66. return host
  67. }
  68. return { ctx, slots, rendererHost }
  69. }
  70. describe('ui-layout client apply', () => {
  71. it('declares its service dependencies', () => {
  72. expect(inject).toEqual(['slots', 'theme', 'locale'])
  73. })
  74. it('provides ctx.layout and declares the four root-scoped frame slots', async () => {
  75. const { ctx, slots } = await bench()
  76. const fiber = ctx.plugin({ inject: [...inject], apply })
  77. await fiber.await()
  78. expect(ctx.get('layout')).toBeInstanceOf(LayoutController)
  79. expect(slots.entries('root')).toHaveLength(1)
  80. expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
  81. expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' })
  82. expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' })
  83. expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' })
  84. })
  85. it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => {
  86. const { ctx, slots, rendererHost } = await bench()
  87. const fiber = ctx.plugin({ inject: [...inject], apply })
  88. await fiber.await()
  89. const entry = slots.entries('root')[0]!
  90. expect(entry.inject).toBeUndefined()
  91. const handle = entry.store as ReturnType<typeof createLayoutStore>
  92. const instance = handle.create()
  93. expect(handle.create()).toBe(instance)
  94. const layout = ctx.get('layout') as LayoutController
  95. expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered')
  96. layout.toggleSidebar()
  97. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
  98. const host = rendererHost()
  99. expect(host.storeOf(entry, undefined)).toBe(instance)
  100. const panelInfo = host.root.getSnapshot().hooks.panelInfo!
  101. expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo)
  102. const panelId = 'panel-a' as MainPanelId
  103. const disposePanel = slots.register({ name: 'main', key: panelId }, () => null)
  104. layout.selectPanel(panelId)
  105. expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId })
  106. disposePanel()
  107. await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) })
  108. expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered')
  109. expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
  110. const pending = layout.beginNavigation()
  111. await fiber.dispose()
  112. expect(pending.aborted).toBe(true)
  113. })
  114. it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => {
  115. const { ctx } = await bench()
  116. const fiber = ctx.plugin({ inject: [...inject], apply })
  117. await fiber.await()
  118. // Initial getter application: jsdom has no matchMedia, system resolves light.
  119. expect(document.documentElement.style.colorScheme).toBe('light')
  120. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
  121. const themeColorMeta = document.head.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
  122. expect(themeColorMeta).not.toBeNull()
  123. const theme = ctx.get('theme') as ThemeRuntime
  124. theme.setTheme('dark')
  125. expect(document.documentElement.style.colorScheme).toBe('dark')
  126. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
  127. expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta)
  128. await fiber.dispose()
  129. expect(document.documentElement.style.colorScheme).toBe('')
  130. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
  131. expect(themeColorMeta?.isConnected).toBe(false)
  132. // Listener is off: further theme changes no longer reach the document.
  133. theme.setTheme('light')
  134. theme.setTheme('dark')
  135. expect(document.documentElement.style.colorScheme).toBe('')
  136. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
  137. })
  138. it('teardown unwinds the service, the root registration, and the child declarations', async () => {
  139. const { ctx, slots, rendererHost } = await bench()
  140. const fiber = ctx.plugin({ inject: [...inject], apply })
  141. await fiber.await()
  142. const host = rendererHost()
  143. expect(host.root.getSnapshot().hooks.panelInfo).toBeDefined()
  144. await fiber.dispose()
  145. expect(ctx.get('layout')).toBeUndefined()
  146. expect(slots.entries('root')).toHaveLength(0)
  147. expect(slots.spec('sidebar')).toBeUndefined()
  148. expect(slots.spec('main')).toBeUndefined()
  149. expect(slots.spec('rightbar')).toBeUndefined()
  150. expect(slots.spec('shell.overlay')).toBeUndefined()
  151. expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined()
  152. // The built-in root declaration survives entry teardown (renderer-owned).
  153. expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
  154. })
  155. })
  156. describe('node half', () => {
  157. it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
  158. nodeApply()
  159. expect(true).toBe(true) // reaching here without throw is the contract
  160. })
  161. })