// @vitest-environment jsdom
import { useEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSessionProvider, createSnapshotStore, RootBindingProvider,
useRootBinding, useSessionBinding,
type SessionBinding, type SessionProviderDeps,
} from '@deepseek-ai/dsh-client-web-react'
const makeBinding = (sessionId: string): SessionBinding => ({
sessionId,
session: { useSelector: (() => { throw new Error('unused') }) as never },
ctx: { tag: sessionId },
})
function setup(bindings: Record) {
const current = createSnapshotStore<{ id: string | undefined }>({ id: undefined })
const resolveBinding = vi.fn((id: string) => bindings[id])
const seen: { id: string; binding: SessionBinding; mountCount: number }[] = []
let mounts = 0
function Body({ id }: { id: string }) {
const binding = useSessionBinding()
const mountRef = useRef(0)
useEffect(() => { mounts += 1; mountRef.current = mounts }, [])
seen.push({ id, binding, mountCount: mountRef.current })
return {id}
}
const deps: SessionProviderDeps = {
useCurrent: () => current.useSelector((s) => s.id),
resolveBinding,
renderBody: (id) => ,
}
const SessionProvider = createSessionProvider(deps)
return { current, resolveBinding, SessionProvider, seen, mountCount: () => mounts }
}
describe('createSessionProvider', () => {
it('renders empty without a current session and switches to the body on select', () => {
const { current, SessionProvider } = setup({ s1: makeBinding('s1') })
const view = render( empty} />)
expect(view.container.textContent).toBe('empty')
act(() => { current.update((d) => { d.id = 's1' }) })
expect(view.container.textContent).toBe('s1')
})
it('renders null empty state when renderEmpty is omitted', () => {
const { SessionProvider } = setup({})
const view = render()
expect(view.container.textContent).toBe('')
})
it('falls back to empty when the binding does not resolve', () => {
const { current, SessionProvider } = setup({})
const view = render( empty} />)
act(() => { current.update((d) => { d.id = 'ghost' }) })
expect(view.container.textContent).toBe('empty')
})
it('passes the resolved binding through context and remounts on session switch', () => {
const bindings = { s1: makeBinding('s1'), s2: makeBinding('s2') }
const { current, SessionProvider, seen, mountCount } = setup(bindings)
render()
act(() => { current.update((d) => { d.id = 's1' }) })
expect(seen.at(-1)!.binding).toBe(bindings.s1)
const mountsAfterS1 = mountCount()
act(() => { current.update((d) => { d.id = 's2' }) })
expect(seen.at(-1)!.binding).toBe(bindings.s2)
// key={id} semantics: switching sessions remounts the body subtree.
expect(mountCount()).toBe(mountsAfterS1 + 1)
})
it('does not remount the body when unrelated renders happen on the same session', () => {
const bindings = { s1: makeBinding('s1') }
const { current, SessionProvider, mountCount } = setup(bindings)
const view = render()
act(() => { current.update((d) => { d.id = 's1' }) })
const mounts = mountCount()
view.rerender()
expect(mountCount()).toBe(mounts)
})
})
describe('binding contexts', () => {
it('useSessionBinding throws outside a SessionProvider subtree', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
function Naked() { useSessionBinding(); return null }
expect(() => render()).toThrow(/outside SessionProvider/)
spy.mockRestore()
})
it('RootBindingProvider supplies the root binding; absence throws', () => {
const root: RootBinding = { ctx: { tag: 'root' } }
let got: RootBinding | undefined
function Probe() { got = useRootBinding(); return null }
render()
expect(got).toBe(root)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render()).toThrow(/RootBindingProvider/)
spy.mockRestore()
})
})