workspaces.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
  2. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  3. import type {
  4. IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
  5. } from '@deepseek-ai/dsh-api-workspace-controller/client'
  6. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  7. import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
  8. import { workspaceSnapshot } from './fixtures.ts'
  9. import type { FixtureSnapshot, Stabilizer } from './fixtures.ts'
  10. /** Writable test representation of the immutable Workspace Controller snapshot. */
  11. type WorkspaceFixtureSnapshot = FixtureSnapshot<WorkspaceSnapshot>
  12. /** Callable command names on the production Workspace Controller face. */
  13. type WorkspaceAction = {
  14. [Key in keyof IWorkspaces]: IWorkspaces[Key] extends (...args: never[]) => unknown ? Key : never
  15. }[keyof IWorkspaces]
  16. /** Test replacement retaining one Controller command's parameters and result. */
  17. type WorkspaceStub<Key extends WorkspaceAction> = (
  18. ...args: Parameters<IWorkspaces[Key]>
  19. ) => ReturnType<IWorkspaces[Key]>
  20. /**
  21. * Workspaces test double. Implements the same IWorkspaces face features
  22. * receive as `ctx.workspaces`, so a production face change breaks this
  23. * double at compile time. Every action records into {@link
  24. * TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
  25. * richer behavior replace them via {@link TestWorkspaces.stub}.
  26. */
  27. export class TestWorkspaces implements IWorkspaces {
  28. /** The useWorkspaces standard feed. */
  29. readonly list: SnapshotStore<WorkspaceFixtureSnapshot>
  30. /** Calls observed on the action face, newest last. */
  31. readonly calls: { method: string; args: unknown[] }[] = []
  32. /** Replaceable action seat: feature tests may stub richer behavior. */
  33. private readonly stubs = new Map<WorkspaceAction, (...args: unknown[]) => unknown>()
  34. /**
  35. * @param stabilize - the owning runtime's act wrapper.
  36. */
  37. constructor(private readonly stabilize: Stabilizer) {
  38. this.list = createSnapshotStore<WorkspaceFixtureSnapshot>({ ...workspaceSnapshot() })
  39. }
  40. /**
  41. * Update the workspace list state through an immer draft.
  42. * @param mutate - draft mutator.
  43. */
  44. async update(mutate: (draft: WorkspaceFixtureSnapshot) => void): Promise<void> {
  45. await this.stabilize(() => { this.list.update(mutate) })
  46. }
  47. /**
  48. * Replace an action's behavior (the recorded call is still appended first).
  49. * @param method - Controller action name (e.g. 'create').
  50. * @param impl - replacement behavior.
  51. */
  52. stub<Key extends WorkspaceAction>(method: Key, impl: WorkspaceStub<Key>): void {
  53. this.stubs.set(method, impl as (...args: unknown[]) => unknown)
  54. }
  55. /**
  56. * Create a Workspace (recorded). The default echoes a view derived from
  57. * the input; stub for failure or list-coupled flows.
  58. * @param input - the Host create payload.
  59. * @returns the created Workspace view.
  60. */
  61. async create(input: { path: string }): Promise<WorkspaceView> {
  62. this.calls.push({ method: 'create', args: [input] })
  63. const stub = this.stubs.get('create')
  64. if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
  65. return {
  66. workspaceId: `ws-${input.path}` as WorkspaceId,
  67. title: input.path,
  68. path: input.path,
  69. sessionIds: [],
  70. } as unknown as WorkspaceView
  71. }
  72. /**
  73. * Rename a Workspace (recorded). The default echoes a minimal view.
  74. * @param workspaceId - target workspace.
  75. * @param title - new title.
  76. * @returns the updated view.
  77. */
  78. async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
  79. this.calls.push({ method: 'rename', args: [workspaceId, title] })
  80. const stub = this.stubs.get('rename')
  81. if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
  82. return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
  83. }
  84. /**
  85. * Delete a Workspace (recorded; default no-op).
  86. * @param workspaceId - target workspace.
  87. */
  88. async delete(workspaceId: WorkspaceId): Promise<void> {
  89. this.calls.push({ method: 'delete', args: [workspaceId] })
  90. await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
  91. }
  92. /**
  93. * Move a Workspace in display order (recorded; default no-op).
  94. * @param workspaceId - Workspace to move.
  95. * @param beforeWorkspaceId - Anchor; omitted appends.
  96. */
  97. async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
  98. this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] })
  99. await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise<void> | undefined)
  100. }
  101. /**
  102. * Move an accounted session (recorded). The default echoes a minimal view.
  103. * @param workspaceId - target workspace.
  104. * @param sessionId - session to move.
  105. * @param beforeSessionId - anchor; omitted appends.
  106. * @returns the updated view.
  107. */
  108. async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
  109. this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
  110. const stub = this.stubs.get('insertSessionBefore')
  111. if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
  112. return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
  113. }
  114. /**
  115. * Archive a session (recorded). The default mirrors the production face's
  116. * observable effect: the id joins the list state's archive set.
  117. * @param sessionId - session to archive.
  118. */
  119. async archiveSession(sessionId: SessionId): Promise<void> {
  120. this.calls.push({ method: 'archiveSession', args: [sessionId] })
  121. const stub = this.stubs.get('archiveSession')
  122. if (stub !== undefined) {
  123. await (stub(sessionId) as Promise<void>)
  124. return
  125. }
  126. await this.update((draft) => {
  127. draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId]
  128. })
  129. }
  130. }