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

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