1
0

scoped-slots-real-core.spec.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. // @vitest-environment jsdom
  2. /**
  3. * Integration against the real ui-slots SlotCore through a passthrough host:
  4. * registrations go through the real register() (options form, children
  5. * declaration), and the outlets ride the real subscribe/getVersion/entries/
  6. * isLive surfaces — microtask-batched notifications, mutation-stable entry
  7. * references (the cache axis), and ledger-fed stale bindings are the
  8. * real-core semantics the fake-host suite cannot vouch for.
  9. */
  10. import { describe, expect, it, vi } from 'vitest'
  11. import { act, render } from '@testing-library/react'
  12. import { SlotCore, type PropsRenderSlots, type SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
  13. import { createSlotRenderer, StaleAuthorizationError } from '@deepseek-ai/dsh-client-web-react'
  14. declare module '@deepseek-ai/dsh-client-ui-slots' {
  15. interface SlotMap {
  16. // No 'root' merge: the aggregate client program already carries runtime's
  17. // authoritative 'root' declaration (a private merge would TS2717-collide);
  18. // only this suite's own test keys merge here.
  19. 'spec.single': { kind: 'single'; scope: 'root'; owner: { label?: string } }
  20. 'spec.list': { kind: 'list'; scope: 'root' }
  21. }
  22. }
  23. type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
  24. /** Passthrough host over the real core (store/session seats unused here). */
  25. function hostOver(core: SlotCore): SlotRendererHost {
  26. const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
  27. return {
  28. subscribe: (key, fn) => core.subscribe(key, fn),
  29. getVersion: key => core.getVersion(key),
  30. entriesOf: key => core.entries(key),
  31. specOf: key => core.specDynamic(key),
  32. isLive: entry => core.isLive(entry),
  33. storeOf: () => undefined,
  34. sessions: {
  35. list: { getSnapshot: () => ({}), subscribe: () => () => {} },
  36. provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
  37. },
  38. workspaces: {
  39. list: { getSnapshot: () => ({}), subscribe: () => () => {} },
  40. },
  41. }
  42. }
  43. /** Register the root frame (declaring both child keys) and mount the renderer. */
  44. function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot']) => React.ReactNode) {
  45. const dispose = core.register({
  46. name: 'root',
  47. children: {
  48. 'spec.single': { kind: 'single', scope: 'root' },
  49. 'spec.list': { kind: 'list', scope: 'root' },
  50. },
  51. }, (props: FrameSlots) => <>{body(props.renderSlot)}</>)
  52. const view = render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}</>)
  53. return { view, dispose }
  54. }
  55. describe('createSlotRenderer over the real SlotCore', () => {
  56. it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
  57. const core = new SlotCore()
  58. const { view } = mountFrame(core, renderSlot =>
  59. renderSlot('spec.single', {}, { fallback: <i>none</i> }))
  60. expect(view.container.textContent).toBe('none')
  61. let dispose = () => {}
  62. // The real core batches subscriber notification per microtask: async act.
  63. await act(async () => {
  64. dispose = core.register({ name: 'spec.single' }, ({ label }: { label?: string }) => <b>{label ?? 'on'}</b>)
  65. })
  66. expect(view.container.textContent).toBe('on')
  67. await act(async () => { dispose(); dispose() }) // disposer is idempotent in the real core
  68. expect(view.container.textContent).toBe('none')
  69. })
  70. it('coalesces same-tick mutations into one notification (uSES pairing stays consistent)', async () => {
  71. const core = new SlotCore()
  72. const notified = vi.fn()
  73. core.subscribe('spec.list', notified)
  74. const { view } = mountFrame(core, renderSlot => renderSlot('spec.list', {}))
  75. await act(async () => {
  76. core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
  77. core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)
  78. })
  79. expect(notified).toHaveBeenCalledTimes(1) // two same-tick mutations, one batch
  80. expect(view.container.textContent).toBe('12')
  81. })
  82. it('passes owner props through and keeps sibling entries() references stable across mutations', async () => {
  83. const core = new SlotCore()
  84. core.register({ name: 'root', children: {
  85. 'spec.single': { kind: 'single', scope: 'root' },
  86. 'spec.list': { kind: 'list', scope: 'root' },
  87. } }, (props: FrameSlots) => <>
  88. {props.renderSlot('spec.single', { label: 'owner' })}
  89. {props.renderSlot('spec.list', {})}
  90. </>)
  91. core.register({ name: 'spec.single' }, ({ label }: { label?: string }) => <b>{label}</b>)
  92. const view = render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}</>)
  93. expect(view.container.textContent).toBe('owner')
  94. // A mutation on the sibling key leaves this key's entries() reference
  95. // untouched (real-core stability the inject/renderSlot caches key on).
  96. const before = core.entries('spec.single')
  97. await act(async () => { core.register({ name: 'spec.list', id: 'l' }, () => <span>L</span>) })
  98. expect(core.entries('spec.single')).toBe(before)
  99. expect(view.container.textContent).toBe('ownerL')
  100. })
  101. it('feeds stale bindings from the real ledger: a disposed registration throws off isLive', () => {
  102. const core = new SlotCore()
  103. let captured: FrameSlots['renderSlot'] | undefined
  104. const { view, dispose } = mountFrame(core, (renderSlot) => {
  105. captured = renderSlot
  106. return null
  107. })
  108. expect(captured!('spec.single', {})).not.toBeUndefined() // live binding renders
  109. // Unmount before disposing: an empty 'root' makes a LIVE root outlet
  110. // rethrow boot-order (covered in the fake-host suite); the scenario here
  111. // is a retained closure outliving both tree and registration.
  112. view.unmount()
  113. dispose()
  114. expect(() => captured!('spec.single', {})).toThrow(StaleAuthorizationError)
  115. })
  116. })