fixtures.client.ts 7.7 KB

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