use-projection.client.spec.tsx 5.6 KB

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