| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- // @vitest-environment jsdom
- import { Fragment, useEffect, useRef } from 'react'
- import { Context } from '@deepseek-ai/cordis'
- import { describe, expect, it, vi } from 'vitest'
- import { act, render } from '@testing-library/react'
- import type {
- SessionProviderComponent, StoredEntry,
- } from '@deepseek-ai/dsh-client-ui-slots'
- import type {
- ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
- } from '@deepseek-ai/dsh-client-ui-renderer/client'
- import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
- type SessionBinding = ScopedStandardSourceBinding
- function observable<T>(initial: T) {
- let value = initial
- const subs = new Set<() => void>()
- return {
- getSnapshot: () => value,
- subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
- set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
- }
- }
- /**
- * Minimal host: SessionProvider only reads the Session scope adapter, but it must
- * render inside the renderer tree (HostContext), so the harness mounts a real
- * root entry whose body is the test's render-prop provider.
- */
- function makeHost(
- bodies: {
- root: (
- rp: (key: string, owner: object) => React.ReactNode,
- SessionProvider: SessionProviderComponent,
- ) => React.ReactNode
- },
- options: { installRenderArea?: boolean } = {},
- ) {
- const scopeCtx = new Context()
- const absentBinding: StandardSourceBinding = {
- key: undefined,
- hooks: { session: undefined },
- keyedHooks: {},
- props: { sessionId: undefined },
- }
- const currentBinding = observable<StandardSourceBinding>(absentBinding)
- let currentId: string | undefined
- const bindings = new Map<string, SessionBinding>()
- const sessionEntries: StoredEntry[] = []
- const root = observable<StandardSourceBinding>({
- key: undefined,
- hooks: {},
- keyedHooks: {},
- props: {},
- })
- const sessionAdapter: SlotScopeAdapter = {
- current: currentBinding,
- resolve: key => bindings.get(key),
- ...(options.installRenderArea === false
- ? {}
- : {
- renderArea: (binding, { empty, children }) => binding.key === undefined
- ? <>{empty?.() ?? null}</>
- : <Fragment key={binding.key}>{children}</Fragment>,
- }),
- }
- const rootEntry: StoredEntry = {
- component: (props: {
- renderSlot: (key: string, owner: object) => React.ReactNode
- SessionProvider: SessionProviderComponent
- }) => <>{bodies.root(props.renderSlot, props.SessionProvider)}</>,
- options: {},
- children: { 'k.session': { kind: 'single', scope: 'session' } },
- }
- const host: SlotRendererHost = {
- subscribe: () => () => {},
- getVersion: () => 0,
- entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
- // Single-kind everywhere and no crashes in this suite: the projection is
- // the raw view and crash reports never fire.
- entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
- reportEntryError: () => {},
- specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
- isLive: () => true,
- storeOf: () => undefined,
- root,
- scopeRevision: observable(0),
- scope: () => sessionAdapter,
- }
- return {
- host,
- // Driver surface: set(id) publishes the resolved binding (or the absent
- // projection) through the scope adapter.
- current: {
- set: (id: string | undefined) => {
- currentId = id
- currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
- },
- },
- addSession: (id: string) => {
- // Bare source per binding (identity-stable): the machinery binds useSession from it.
- const binding: SessionBinding = {
- key: id,
- ctx: scopeCtx,
- hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
- keyedHooks: {},
- props: { sessionId: id },
- }
- bindings.set(id, binding)
- if (currentId === id) currentBinding.set(binding)
- return binding
- },
- /** Swap one session's binding in place (roster-change stand-in); republish when current. */
- replaceSession: (binding: SessionBinding) => {
- bindings.set(binding.key, binding)
- if (currentId === binding.key) currentBinding.set(binding)
- },
- registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
- }
- }
- describe('SessionProvider', () => {
- it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
- const h = makeHost({
- root: (_renderSlot, SessionProvider) => (
- <SessionProvider empty={() => <span>empty</span>}>
- <div data-testid="body">session</div>
- </SessionProvider>
- ),
- })
- h.addSession('s1')
- const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
- expect(view.container.textContent).toBe('empty')
- act(() => { h.current.set('s1') })
- expect(view.container.textContent).toBe('session')
- act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
- expect(view.container.textContent).toBe('empty')
- })
- it('renders null empty state when the empty prop is omitted', () => {
- const h = makeHost({
- root: (_renderSlot, SessionProvider) => <SessionProvider><b>session</b></SessionProvider>,
- })
- const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
- expect(view.container.textContent).toBe('')
- })
- it('remounts the body on session switch (key semantics) but not on unrelated re-renders', () => {
- let mounts = 0
- function Body({ id }: { id: string }) {
- const mounted = useRef(false)
- useEffect(() => {
- /* v8 ignore next -- strict-mode double-invoke guard, not a branch under test */
- if (!mounted.current) { mounted.current = true; mounts += 1 }
- }, [])
- return <div>{id}</div>
- }
- const h = makeHost({
- root: (renderSlot, SessionProvider) => (
- <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
- ),
- })
- h.registerSession({
- component: (props: { sessionId?: string }) => <Body id={props.sessionId ?? 'missing'} />,
- options: {},
- })
- h.addSession('s1')
- h.addSession('s2')
- const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
- act(() => { h.current.set('s1') })
- const afterS1 = mounts
- act(() => { h.current.set('s2') })
- expect(mounts).toBe(afterS1 + 1)
- const afterS2 = mounts
- view.rerender(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
- expect(mounts).toBe(afterS2)
- })
- it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
- const seen: Record<string, unknown>[] = []
- const h = makeHost({
- root: (renderSlot, SessionProvider) => (
- <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
- ),
- })
- h.addSession('s1')
- h.addSession('s2')
- h.registerSession({
- component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
- // The bound hook reads the cell's bare source — asserting through it
- // proves the machinery wired THIS session's source, not another's.
- seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
- return null
- },
- options: {},
- })
- render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
- act(() => { h.current.set('s1') })
- expect(seen.at(-1)!['read']).toBe('s1')
- expect(seen.at(-1)!['sessionId']).toBe('s1')
- act(() => { h.current.set('s2') })
- expect(seen.at(-1)!['read']).toBe('s2')
- expect(seen.at(-1)!['sessionId']).toBe('s2')
- })
- it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
- const seen: unknown[] = []
- const h = makeHost({
- root: (renderSlot, SessionProvider) => (
- <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
- ),
- })
- const original = h.addSession('s1')
- h.registerSession({
- component: (props: { feature?: string }) => {
- seen.push(props.feature)
- return null
- },
- options: {},
- })
- render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
- act(() => { h.current.set('s1') })
- expect(seen.at(-1)).toBeUndefined()
- // A provider-roster change rematerializes the binding; the scope source
- // must carry it to already-mounted entries without a selection change.
- act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
- expect(seen.at(-1)).toBe('now-live')
- })
- it('fails loud when the Session scope owner omits its area renderer', () => {
- const h = makeHost({ root: () => null }, { installRenderArea: false })
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
- expect(() => render(<>{createSlotRenderer().renderRoot(h.host, {})}</>))
- .toThrow(/does not provide its area renderer/)
- spy.mockRestore()
- })
- })
|