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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  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. return {
  27. subscribe: (key, fn) => core.subscribe(key, fn),
  28. getVersion: key => core.getVersion(key),
  29. entriesOf: key => core.entries(key),
  30. specOf: key => core.specDynamic(key),
  31. isLive: entry => core.isLive(entry),
  32. storeOf: () => undefined,
  33. sessions: {
  34. list: { getSnapshot: () => ({}), subscribe: () => () => {} },
  35. current: { getSnapshot: () => undefined, subscribe: () => () => {} },
  36. provideInfo: () => undefined,
  37. maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
  38. },
  39. workspaces: {
  40. list: { getSnapshot: () => ({}), subscribe: () => () => {} },
  41. },
  42. }
  43. }
  44. /** Register the root frame (declaring both child keys) and mount the renderer. */
  45. function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot']) => React.ReactNode) {
  46. const dispose = core.register({
  47. name: 'root',
  48. children: {
  49. 'spec.single': { kind: 'single', scope: 'root' },
  50. 'spec.list': { kind: 'list', scope: 'root' },
  51. },
  52. }, (props: FrameSlots) => <>{body(props.renderSlot)}</>)
  53. const view = render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}</>)
  54. return { view, dispose }
  55. }
  56. describe('createSlotRenderer over the real SlotCore', () => {
  57. it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
  58. const core = new SlotCore()
  59. const { view } = mountFrame(core, renderSlot =>
  60. renderSlot('spec.single', {}, { fallback: <i>none</i> }))
  61. expect(view.container.textContent).toBe('none')
  62. let dispose = () => {}
  63. // The real core batches subscriber notification per microtask: async act.
  64. await act(async () => {
  65. dispose = core.register({ name: 'spec.single' }, ({ label }: { label?: string }) => <b>{label ?? 'on'}</b>)
  66. })
  67. expect(view.container.textContent).toBe('on')
  68. await act(async () => { dispose(); dispose() }) // disposer is idempotent in the real core
  69. expect(view.container.textContent).toBe('none')
  70. })
  71. it('coalesces same-tick mutations into one notification (uSES pairing stays consistent)', async () => {
  72. const core = new SlotCore()
  73. const notified = vi.fn()
  74. core.subscribe('spec.list', notified)
  75. const { view } = mountFrame(core, renderSlot => renderSlot('spec.list', {}))
  76. await act(async () => {
  77. core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
  78. core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)
  79. })
  80. expect(notified).toHaveBeenCalledTimes(1) // two same-tick mutations, one batch
  81. expect(view.container.textContent).toBe('12')
  82. })
  83. it('passes owner props through and keeps sibling entries() references stable across mutations', async () => {
  84. const core = new SlotCore()
  85. core.register({ name: 'root', children: {
  86. 'spec.single': { kind: 'single', scope: 'root' },
  87. 'spec.list': { kind: 'list', scope: 'root' },
  88. } }, (props: FrameSlots) => <>
  89. {props.renderSlot('spec.single', { label: 'owner' })}
  90. {props.renderSlot('spec.list', {})}
  91. </>)
  92. core.register({ name: 'spec.single' }, ({ label }: { label?: string }) => <b>{label}</b>)
  93. const view = render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}</>)
  94. expect(view.container.textContent).toBe('owner')
  95. // A mutation on the sibling key leaves this key's entries() reference
  96. // untouched (real-core stability the inject/renderSlot caches key on).
  97. const before = core.entries('spec.single')
  98. await act(async () => { core.register({ name: 'spec.list', id: 'l' }, () => <span>L</span>) })
  99. expect(core.entries('spec.single')).toBe(before)
  100. expect(view.container.textContent).toBe('ownerL')
  101. })
  102. it('feeds stale bindings from the real ledger: a disposed registration throws off isLive', () => {
  103. const core = new SlotCore()
  104. let captured: FrameSlots['renderSlot'] | undefined
  105. const { view, dispose } = mountFrame(core, (renderSlot) => {
  106. captured = renderSlot
  107. return null
  108. })
  109. expect(captured!('spec.single', {})).not.toBeUndefined() // live binding renders
  110. // Unmount before disposing: an empty 'root' makes a LIVE root outlet
  111. // rethrow boot-order (covered in the fake-host suite); the scenario here
  112. // is a retained closure outliving both tree and registration.
  113. view.unmount()
  114. dispose()
  115. expect(() => captured!('spec.single', {})).toThrow(StaleAuthorizationError)
  116. })
  117. })