fixtures.client.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. /**
  2. * Shared harness for the body specs: a real store instance, a real face over a
  3. * scripted paged read, a scripted `useResource`, and the owner props a tab
  4. * record carries.
  5. *
  6. * The framework's standard kit is replaced by the few members these components
  7. * read, behind one documented cast, so the specs exercise the components and
  8. * not the slot runtime.
  9. */
  10. import { onTestFinished, vi } from 'vitest'
  11. import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
  12. import type { Mock } from 'vitest'
  13. import { act } from '@testing-library/react'
  14. import { createElement, useSyncExternalStore } from 'react'
  15. import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
  16. import type { ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client'
  17. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  18. import type { WorkspaceFileStat, WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
  19. import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
  20. import { textFace } from '../src/client/face.ts'
  21. import type { TextInjected } from '../src/client/face.ts'
  22. import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts'
  23. import { createTextStore } from '../src/client/store.ts'
  24. import type { TextStore } from '../src/client/store.ts'
  25. import type { DocumentPreviewProps } from '../src/client/document/contract.ts'
  26. import { TextBody } from '../src/client/text/TextBody.tsx'
  27. import { textBodyDefinition } from '../src/client/text/index.ts'
  28. import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
  29. type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot']
  30. /** Preserve the body-slot callback used by component fixtures. */
  31. export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { return body }
  32. export const TAB_ID = 'tab-1' as TabId
  33. export const SESSION = 's-1' as SessionId
  34. /** The path relative to the session's workspace root, as the Host receives it. */
  35. export const PATH = 'work/notes.md'
  36. export const ABSOLUTE_PATH = '/host/project/work/notes.md'
  37. /** The tab's address: the file under this session's scope. */
  38. export const ADDRESS = 'dsh-resource://file/session/s-1/work/notes.md'
  39. /** What the address names, as the face receives it. */
  40. export const FILE: SessionFile = { sessionId: SESSION, path: PATH }
  41. /** One page the Host would return: the lines joined without a terminator, and their count. */
  42. export function page(offset: number, lines: readonly string[], eof: boolean, version = 'v1'): RemoteResult<WorkspaceFileText> {
  43. return { ok: true, value: { absolutePath: ABSOLUTE_PATH, version, offset, text: lines.join('\n'), lines: lines.length, eof, bytes: 100 } }
  44. }
  45. /** One failed page read. */
  46. export function failure(code: string, details: Record<string, unknown> = {}): RemoteResult<WorkspaceFileText> {
  47. return { ok: false, error: { code, message: 'boom', details } as unknown as RemoteFailure }
  48. }
  49. /** The `file` resource's metadata: live, or failed beside the last live value. */
  50. function meta(
  51. version: string | undefined, failure: RemoteFailure | undefined,
  52. ): ResourceSnapshot<WorkspaceFileStat> {
  53. const value = version === undefined ? undefined : { absolutePath: ABSOLUTE_PATH, version, bytes: 100 }
  54. return failure === undefined
  55. ? { status: version === undefined ? 'loading' : 'live', value, failure: undefined }
  56. : { status: 'failed', value, failure }
  57. }
  58. /** Test-local selector hook over a framework-neutral store instance. */
  59. function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
  60. return function useSelector<S>(sel: (s: T) => S): S {
  61. return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
  62. }
  63. }
  64. /** Key-echoing translate that also shows its parameters. */
  65. export function t(key: string, params?: Record<string, unknown>): string {
  66. return params === undefined ? key : `${key}(${Object.entries(params).map(([k, v]) => `${k}=${String(v)}`).join(',')})`
  67. }
  68. /** Flush page reads that resolved since the last render, then React's work. */
  69. export async function settle(): Promise<void> {
  70. await act(async () => {
  71. await Promise.resolve()
  72. await Promise.resolve()
  73. })
  74. }
  75. /** What one tab record's harness hands a spec. Named so the helper's declaration stays portable. */
  76. export interface Harness {
  77. /** The live store instance both components read. */
  78. instance: ReturnType<TextStore['create']>
  79. /** The face bound to the scripted read. */
  80. face: TextInjected
  81. /** The scripted paged read. */
  82. read: Mock<ReadWorkspaceFilePage>
  83. /** The complete byte reader. */
  84. bytes: Mock<ReadDocumentBytes>
  85. /** The tab record's lifetime. */
  86. controller: AbortController
  87. /** Current file metadata. */
  88. readonly file: WorkspaceFileStat | undefined
  89. /** The scripted `useResource`. */
  90. useResource: Mock<() => ResourceSnapshot<WorkspaceFileStat>>
  91. /** Composed props for one navigation state. */
  92. props: (navigation?: { params?: unknown; revision: number }) => TextPreviewProps
  93. /** Script what one offset resolves to from now on. */
  94. script(offset: number, result: RemoteResult<WorkspaceFileText>): void
  95. /** Publish another metadata version without acknowledging any tab's content. */
  96. setVersion(version: string | undefined): void
  97. /** Script the next render's `useResource` as failed with `failure`, or live again with `undefined`. */
  98. setFailure(failure: RemoteFailure | undefined): void
  99. }
  100. /**
  101. * One tab record's harness.
  102. * @param script - the page each offset resolves to; an unscripted offset fails `not-found`.
  103. * @param tabId - owning tab record.
  104. * @returns the store, the scripted faces, and a props builder.
  105. */
  106. export function harness(script: Record<number, RemoteResult<WorkspaceFileText>> = {}, tabId = TAB_ID): Harness {
  107. const instance = createTextStore().create()
  108. const pages: Record<number, RemoteResult<WorkspaceFileText>> = { ...script }
  109. const read = vi.fn<ReadWorkspaceFilePage>((_session, _path, offset) =>
  110. Promise.resolve(pages[offset] ?? failure('workspace-file/not-found', { path: PATH })))
  111. const bytes = vi.fn<ReadDocumentBytes>()
  112. const face = textFace(read, bytes)(SESSION, instance.actions)
  113. const current = { version: 'v1' as string | undefined, failure: undefined as RemoteFailure | undefined, snapshot: meta('v1', undefined) }
  114. const refresh = (): void => { current.snapshot = meta(current.version, current.failure) }
  115. const useResource = vi.fn<() => ResourceSnapshot<WorkspaceFileStat>>(() => current.snapshot)
  116. const controller = new AbortController()
  117. onTestFinished(() => { controller.abort() })
  118. const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() }
  119. const definitions = [textBodyDefinition(() => t('viewer.text'))]
  120. const renderSlot = documentSlots((_key, owner, opts) => createElement(TextBody, {
  121. ...owner, useTabInfo: opts.hookContext, sessionId: SESSION, useResource,
  122. } as unknown as DocumentPreviewProps))
  123. const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({
  124. useTabInfo: () => ({
  125. sidebar: { expanded: true, fullscreen: false },
  126. panel: { id: 'pane-1' },
  127. tab: {
  128. id: tabId, kind: 'text', contentId: ADDRESS, title: 'notes.md', visible: true,
  129. navigation: { address: ADDRESS, params: navigation.params, revision: navigation.revision },
  130. signal: controller.signal,
  131. actions: tabActions,
  132. },
  133. }),
  134. sessionId: SESSION,
  135. useResource,
  136. useStore: hookOf(instance),
  137. actions: instance.actions,
  138. loadPage: face.loadPage,
  139. reloadPages: face.reloadPages,
  140. prepareRenderer: face.prepareRenderer, loadAll: face.loadAll,
  141. reloadAll: face.reloadAll,
  142. useDocumentPreviews: () => definitions,
  143. renderSlot,
  144. t,
  145. }) as unknown as TextPreviewProps
  146. return {
  147. instance,
  148. face,
  149. read,
  150. bytes,
  151. controller,
  152. get file() { return current.snapshot.value },
  153. useResource,
  154. props,
  155. script(offset, result) { pages[offset] = result },
  156. setVersion(version) { current.version = version; refresh() },
  157. setFailure(failure) { current.failure = failure; refresh() },
  158. }
  159. }