use-projection.spec.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. // @vitest-environment jsdom
  2. /**
  3. * useProjection standard-kit delivery (session-projection RFC): the fifth
  4. * framework hook seat rides the same provide channel as useSession — a
  5. * session slot component receives `useProjection` in its kit, key-addressed
  6. * over the bundle's projection face; unresolved keys (no value, no face, no
  7. * session) uniformly read `undefined`; live value changes re-render; the
  8. * selector overload runs over the whole value.
  9. */
  10. import { describe, expect, it } from 'vitest'
  11. import { act, render } from '@testing-library/react'
  12. import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
  13. import { createSlotRenderer, type SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
  14. function observable<T>(initial: T) {
  15. let value = initial
  16. const subs = new Set<() => void>()
  17. return {
  18. getSnapshot: () => value,
  19. subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
  20. set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
  21. }
  22. }
  23. type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => unknown
  24. function makeHost() {
  25. const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
  26. const provide = observable<SessionMaybeProvideInfo>(absentInfo)
  27. const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
  28. /** Store-parallel face: always defined per key; an unseen key snapshots undefined. */
  29. const absent = { getSnapshot: () => undefined, subscribe: () => () => {} }
  30. const sessionEntries: StoredEntry[] = []
  31. let withFace = true
  32. const rootEntry: StoredEntry = {
  33. component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
  34. <>{props.renderSlot('k.session', {})}</>,
  35. options: {},
  36. children: { 'k.session': { kind: 'single', scope: 'session' } },
  37. }
  38. const info = (id: string): SessionMaybeProvideInfo => ({
  39. sessionId: id,
  40. hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
  41. props: {},
  42. ...(withFace ? { projections: { faceOf: (key: string) => cells.get(key) ?? absent } } : {}),
  43. })
  44. const host: SlotRendererHost = {
  45. subscribe: () => () => {},
  46. getVersion: () => 0,
  47. entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
  48. specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
  49. isLive: () => true,
  50. storeOf: () => undefined,
  51. sessions: {
  52. list: observable<unknown>({ ids: [] }),
  53. provideInfo: provide,
  54. },
  55. workspaces: { list: observable<unknown>({ items: [] }) },
  56. }
  57. return {
  58. host,
  59. cells,
  60. // Same driver surface as before the atomic provide source: set(id)
  61. // publishes the resolved bundle (or the absent projection) through it.
  62. current: { set: (id: string | undefined) => { provide.set(id === undefined ? absentInfo : info(id)) } },
  63. dropFace: () => { withFace = false },
  64. registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
  65. }
  66. }
  67. describe('useProjection standard-kit delivery', () => {
  68. it('reads the projected value through the kit, undefined for unresolved keys, and follows live changes', () => {
  69. const h = makeHost()
  70. const cell = observable<unknown>({ marks: ['a'] })
  71. h.cells.set('test/marks', cell)
  72. const reads: Record<string, unknown>[] = []
  73. h.registerSession({
  74. component: (props: { useProjection: UseProjectionProp }) => {
  75. reads.push({
  76. marks: props.useProjection('test/marks'),
  77. ghost: props.useProjection('test/ghost'),
  78. })
  79. return null
  80. },
  81. options: {},
  82. })
  83. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  84. act(() => { h.current.set('s1') })
  85. expect(reads.at(-1)).toEqual({ marks: { marks: ['a'] }, ghost: undefined })
  86. // Live change re-renders with the new whole value.
  87. act(() => { cell.set({ marks: ['a', 'b'] }) })
  88. expect(reads.at(-1)).toEqual({ marks: { marks: ['a', 'b'] }, ghost: undefined })
  89. })
  90. it('runs the selector overload over the whole value (and over undefined when absent)', () => {
  91. const h = makeHost()
  92. h.cells.set('test/marks', observable<unknown>({ marks: ['x', 'y'] }))
  93. const reads: unknown[] = []
  94. h.registerSession({
  95. component: (props: { useProjection: UseProjectionProp }) => {
  96. reads.push(props.useProjection('test/marks', v => (v as { marks: string[] } | undefined)?.marks.length ?? -1))
  97. reads.push(props.useProjection('test/ghost', v => (v === undefined ? 'absent' : 'present')))
  98. return null
  99. },
  100. options: {},
  101. })
  102. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  103. act(() => { h.current.set('s1') })
  104. expect(reads.slice(-2)).toEqual([2, 'absent'])
  105. })
  106. it('treats a bundle without the projections face as all-absent (capability absence)', () => {
  107. const h = makeHost()
  108. h.cells.set('test/marks', observable<unknown>({ marks: ['a'] }))
  109. h.dropFace()
  110. const reads: unknown[] = []
  111. h.registerSession({
  112. component: (props: { useProjection: UseProjectionProp }) => {
  113. reads.push(props.useProjection('test/marks'))
  114. return null
  115. },
  116. options: {},
  117. })
  118. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  119. act(() => { h.current.set('s1') })
  120. expect(reads.at(-1)).toBeUndefined()
  121. })
  122. })