Просмотр исходного кода

test(ui-message-feedback): use RemoteMock

imccyu 2 недель назад
Родитель
Сommit
77db56687b

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
-2026-09-06-client-assembly-test-line.md: 36f7e2ab11248628dca7aff53cae4a9c9f84e441
-2026-09-06-client-assembly-test-line.zh.md: 0ef64a97e25b062a858ddbd1796f86e59591cd51
+2026-09-06-client-assembly-test-line.md: f3b43ec9a4b08ac7b3a9fc802136ae916da9eb65
+2026-09-06-client-assembly-test-line.zh.md: 95445c584e7484080610da2347a968f5202f14f8

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md

@@ -89,4 +89,4 @@ Product facts the tier surfaced and leaves as they are:
 
 ## Testing
 
-`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.<ns>` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state.
+`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Eight converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.<ns>` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state.

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md

@@ -89,4 +89,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远
 
 ## 测试
 
-`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.<ns>` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。
+`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。八条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.<ns>` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。

+ 1 - 0
packages/client/ui-message-feedback/package.json

@@ -55,6 +55,7 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-message-feedback": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",

+ 6 - 7
packages/client/ui-message-feedback/src/client/controller.ts

@@ -7,9 +7,8 @@
  * @module @deepseek-ai/dsh-client-ui-message-feedback/client/controller
  */
 
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
-import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote, MessageId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types'
 import type {
@@ -95,11 +94,11 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
   private disposed = false
 
   /**
-   * @param ctx - the browser plugin context carrying the messageFeedback Remote namespace.
+   * @param remote - the messageFeedback Remote namespace.
    * @param sessionId - Session owning every addressed assistant message.
    */
   constructor(
-    private readonly ctx: ClientContext,
+    private readonly remote: ClientRemote['messageFeedback'],
     private readonly sessionId: SessionId,
   ) {}
 
@@ -194,7 +193,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
     entry: FeedbackRecord,
     observed: MessageFeedbackItem | undefined,
   ): Promise<MessageFeedbackActionResult> {
-    const carried = await this.ctx.remote.messageFeedback.put({
+    const carried = await this.remote.put({
       sessionId: this.sessionId,
       messageId,
       rating,
@@ -217,7 +216,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
     messageId: MessageId,
     observed: MessageFeedbackItem,
   ): Promise<MessageFeedbackActionResult> {
-    const carried = await this.ctx.remote.messageFeedback.delete({
+    const carried = await this.remote.delete({
       sessionId: this.sessionId,
       messageId,
       ifVersion: observed.version,
@@ -240,7 +239,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
 
   /** Fetch the whole sidecar and publish it as the seeded view. */
   private async load(): Promise<MessageFeedbackActionResult> {
-    const carried = await this.ctx.remote.messageFeedback.list({ sessionId: this.sessionId })
+    const carried = await this.remote.list({ sessionId: this.sessionId })
     if (this.disposed) return OK
     if (!carried.ok) {
       this.publish({ status: 'error', items: this.view.items, error: carried.error.message })

+ 1 - 1
packages/client/ui-message-feedback/src/client/surface.ts

@@ -24,7 +24,7 @@ export class FeedbackSurface {
    * @param sessionId - Session owning the transcript and the remark.
    */
   constructor(private readonly ctx: ClientContext, private readonly sessionId: SessionId) {
-    this.feedback = new MessageFeedbackController(ctx, sessionId)
+    this.feedback = new MessageFeedbackController(ctx.remote.messageFeedback, sessionId)
     this.dialog = new FeedbackDialogController((target, entry) => target.kind === 'message'
       ? this.feedback.rate(target.messageId, target.rating, entry)
       : this.recordSession(entry))

+ 151 - 206
packages/client/ui-message-feedback/tests/controller.client.spec.ts

@@ -5,8 +5,8 @@
  * reconciles from the authoritative item carried by the reply, mutations
  * serialize per Session, and a disposed controller stops publishing.
  */
-import { describe, expect, it, vi } from 'vitest'
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import { RemoteMock, ok } from '@deepseek-ai/dsh-remote-mock'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
 import type { MessageId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import type {
@@ -31,49 +31,20 @@ function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem
   }
 }
 
-/** A recording fake Remote whose per-method answers are scripted per call. */
-type Script = {
-  list?: (request: unknown) => Promise<unknown>
-  put?: (request: unknown) => Promise<unknown>
-  delete?: (request: unknown) => Promise<unknown>
-}
+let mock: RemoteMock
 
-/**
- * A recording fake context. Scripts return the *business* result; this wraps it
- * in the carrier envelope the generated face uses, so specs stay readable. A
- * script may also return an already-enveloped `{ok:false,error:RemoteError}` to
- * exercise a carrier failure.
- */
-function fakeRemote(script: Script = {}) {
-  const calls: { method: string; request: unknown }[] = []
-  const isCarrier = (v: unknown): boolean =>
-    typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
-      && 'error' in v && 'details' in ((v as { error: object }).error ?? {})
-  const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
-    (request: never): Promise<never> => {
-      calls.push({ method, request })
-      const business = real === undefined ? Promise.resolve(fallback) : real(request)
-      return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
-    }
-  const ctx = {
-    remote: {
-      messageFeedback: {
-        list: record('list', script.list, { ok: true, value: { items: [] } }),
-        put: record('put', script.put, { ok: true, value: item() }),
-        delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
-      },
-    },
-  } as unknown as ClientContext
-  return { ctx, calls }
-}
+beforeEach(() => {
+  mock = RemoteMock.create()
+  mock.remote.messageFeedback.list.mockResolvedValue(ok({ ok: true, value: { items: [] } }))
+  mock.remote.messageFeedback.put.mockResolvedValue(ok({ ok: true, value: item() }))
+  mock.remote.messageFeedback.delete.mockResolvedValue(ok({ ok: true, value: { absent: true } }))
+})
 
 describe('MessageFeedbackController', () => {
   it('seeds the view from one list read and keys items by message id', async () => {
     const seeded = item({ note: 'good' })
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [seeded] } }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(controller.getSnapshot().status).toBe('cold')
     expect(await controller.ensure()).toEqual({ ok: true })
@@ -81,73 +52,68 @@ describe('MessageFeedbackController', () => {
     const view = controller.getSnapshot()
     expect(view.status).toBe('ready')
     expect(view.items.get(MSG)).toEqual(seeded)
-    expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }])
+    expect(mock.remote.messageFeedback.list).toHaveBeenCalledExactlyOnceWith({ sessionId: SESSION })
   })
 
   it('collapses concurrent loads onto one in-flight read', async () => {
-    const { ctx, calls } = fakeRemote()
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()])
 
-    expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
+    expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1)
   })
 
   it('sends ifVersion null for a first rating and the observed version afterwards', async () => {
     const first = item({ version: version('v1') })
     const second = item({ version: version('v2'), rating: 'negative' })
-    const { ctx, calls } = fakeRemote({
-      put: request => Promise.resolve({
-        ok: true,
-        value: (request as { rating: string }).rating === 'positive' ? first : second,
-      }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.put.mockImplementation(request => Promise.resolve(ok({
+      ok: true,
+      value: request.rating === 'positive' ? first : second,
+    })))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true })
     expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
 
-    const puts = calls.filter(call => call.method === 'put').map(call => call.request)
+    const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
     expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null })
     expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') })
     expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
   })
 
   it('forwards the entry as note and category and omits absent members', async () => {
-    const { ctx, calls } = fakeRemote()
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     await controller.rate(MSG, 'negative', { text: 'helpful', category: 'task-result' })
     await controller.rate(OTHER, 'negative')
 
-    const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
+    const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
     expect(puts[0]).toMatchObject({ note: 'helpful', category: 'task-result' })
     expect(puts[1]).not.toHaveProperty('note')
     expect(puts[1]).not.toHaveProperty('category')
   })
 
   it('retract deletes a matching rating and ignores an opposite judgment', async () => {
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [item({ note: 'stored', category: 'other' })] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
+      ok: true, value: { items: [item({ note: 'stored', category: 'other' })] },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.retract(MSG, 'negative')).toEqual({ ok: true })
     expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
 
-    expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
-    expect(calls.filter(call => call.method === 'delete')[0]?.request)
-      .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v1') })
+    expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
+    expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({
+      sessionId: SESSION, messageId: MSG, ifVersion: version('v1'),
+    })
   })
 
   it('never turns a queued stale retraction into a bare rating put', async () => {
     const existing = item({ rating: 'positive', version: version('v1') })
     const replacement = item({ rating: 'negative', version: version('v2') })
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
-      put: () => Promise.resolve({ ok: true, value: replacement }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
+    mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ ok: true, value: replacement }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
 
     const replaced = controller.rate(MSG, 'negative')
@@ -155,20 +121,18 @@ describe('MessageFeedbackController', () => {
 
     await expect(replaced).resolves.toEqual({ ok: true })
     await expect(staleRetraction).resolves.toEqual({ ok: true })
-    expect(calls.filter(call => call.method === 'put')).toHaveLength(1)
-    expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
+    expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(1)
+    expect(mock.remote.messageFeedback.delete).not.toHaveBeenCalled()
     expect(controller.getSnapshot().items.get(MSG)).toEqual(replacement)
   })
 
   it('reconciles a version conflict from the authoritative item without refetching', async () => {
     const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
-    const { ctx, calls } = fakeRemote({
-      put: () => Promise.resolve({
-        ok: false,
-        error: { code: 'version-conflict', current: authoritative },
-      }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({
+      ok: false,
+      error: { code: 'version-conflict', current: authoritative },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.rate(MSG, 'positive')).toEqual({
       ok: false,
@@ -176,18 +140,16 @@ describe('MessageFeedbackController', () => {
     })
 
     expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
-    expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
+    expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1)
   })
 
   it('drops the local item when a conflict reports the feedback is gone', async () => {
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
-      delete: () => Promise.resolve({
-        ok: false,
-        error: { code: 'version-conflict', current: null },
-      }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
+    mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({
+      ok: false,
+      error: { code: 'version-conflict', current: null },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
 
     expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
@@ -195,16 +157,17 @@ describe('MessageFeedbackController', () => {
   })
 
   it('deletes with the observed version and removes the item on success', async () => {
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
+      ok: true, value: { items: [item({ version: version('v7') })] },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
 
     expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
 
-    expect(calls.filter(call => call.method === 'delete')[0]?.request)
-      .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
+    expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({
+      sessionId: SESSION, messageId: MSG, ifVersion: version('v7'),
+    })
     expect(controller.getSnapshot().items.has(MSG)).toBe(false)
   })
 
@@ -213,32 +176,30 @@ describe('MessageFeedbackController', () => {
     let overlapped = false
     const versions = [version('v1'), version('v2')]
     let index = 0
-    const { ctx, calls } = fakeRemote({
-      put: async () => {
-        inFlight += 1
-        if (inFlight > 1) overlapped = true
-        await Promise.resolve()
-        inFlight -= 1
-        const next = versions[index] ?? version('vN')
-        index += 1
-        return { ok: true, value: item({ version: next }) }
-      },
+    mock.remote.messageFeedback.put.mockImplementation(async () => {
+      inFlight += 1
+      if (inFlight > 1) overlapped = true
+      await Promise.resolve()
+      inFlight -= 1
+      const next = versions[index] ?? version('vN')
+      index += 1
+      return ok({ ok: true, value: item({ version: next }) })
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
 
     expect(overlapped).toBe(false)
-    const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
+    const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
     expect(puts[0]?.ifVersion).toBeNull()
     expect(puts[1]?.ifVersion).toBe(version('v1'))
   })
 
   it('publishes an error status when the list read is rejected by the Host', async () => {
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
+      ok: false, error: { code: 'session-not-found', sessionId: SESSION },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
     expect(controller.getSnapshot()).toMatchObject({
@@ -248,8 +209,7 @@ describe('MessageFeedbackController', () => {
   })
 
   it('notifies subscribers on publication and stops after unsubscribe', async () => {
-    const { ctx } = fakeRemote()
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     const listener = vi.fn()
     const unsubscribe = controller.subscribe(listener)
 
@@ -263,8 +223,7 @@ describe('MessageFeedbackController', () => {
   })
 
   it('contains a throwing subscriber at the observable boundary', async () => {
-    const { ctx } = fakeRemote()
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
     controller.subscribe(() => { throw new Error('subscriber exploded') })
     const healthy = vi.fn()
@@ -278,17 +237,16 @@ describe('MessageFeedbackController', () => {
   })
 
   it('refuses mutations and stops publishing once disposed', async () => {
-    const { ctx, calls } = fakeRemote()
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
     const listener = vi.fn()
     controller.subscribe(listener)
 
     controller.dispose()
-    const before = calls.length
+    const puts = mock.remote.messageFeedback.put.mock.calls.length
 
     expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
-    expect(calls).toHaveLength(before)
+    expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(puts)
     expect(listener).not.toHaveBeenCalled()
   })
 
@@ -300,20 +258,20 @@ describe('MessageFeedbackController', () => {
       ['note-too-large', 'the note is too long'],
     ] as const
     for (const [code, message] of codes) {
-      const { ctx } = fakeRemote({
-        list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never),
-      })
-      const controller = new MessageFeedbackController(ctx, SESSION)
+      mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
+        ok: false, error: { code, sessionId: SESSION },
+      } as never))
+      const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
       expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
       expect(controller.getSnapshot().error).toBe(message)
     }
   })
 
   it('falls back to the raw code for an unrecognized failure', async () => {
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
+      ok: false, error: { code: 'brand-new-code' },
+    } as never))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
     expect(controller.getSnapshot().error).toBe('brand-new-code')
@@ -322,13 +280,11 @@ describe('MessageFeedbackController', () => {
   it('publishes nothing when the list settles after disposal', async () => {
     let release = (): void => {}
     const gate = new Promise<void>((resolve) => { release = resolve })
-    const { ctx } = fakeRemote({
-      list: async () => {
-        await gate
-        return { ok: true, value: { items: [item()] } }
-      },
+    mock.remote.messageFeedback.list.mockImplementationOnce(async () => {
+      await gate
+      return ok({ ok: true, value: { items: [item()] } })
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     const pending = controller.ensure()
     const listener = vi.fn()
     controller.subscribe(listener)
@@ -342,30 +298,28 @@ describe('MessageFeedbackController', () => {
   })
 
   it('propagates a failed load to a queued mutation without calling the wire', async () => {
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
+      ok: false, error: { code: 'session-not-found', sessionId: SESSION },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.rate(MSG, 'positive')).toMatchObject({
       ok: false,
       error: { code: 'session-not-found' },
     })
-    expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
+    expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
   })
 
   it('keeps a later mutation running after an earlier one settles as a failure', async () => {
     let first = true
-    const { ctx } = fakeRemote({
-      put: () => {
-        if (first) {
-          first = false
-          return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) })
-        }
-        return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) })
-      },
+    mock.remote.messageFeedback.put.mockImplementation(() => {
+      if (first) {
+        first = false
+        return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'first blew up', {}) })
+      }
+      return Promise.resolve(ok({ ok: true, value: item({ rating: 'negative' }) }))
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     const [a, b] = await Promise.all([
       controller.rate(MSG, 'positive'),
@@ -383,14 +337,17 @@ describe('MessageFeedbackController', () => {
     // fiber unloads, and its authoritative item must not be published.
     let release = (): void => {}
     const gate = new Promise<void>((resolve) => { release = resolve })
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }),
-      put: async () => {
-        await gate
-        return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } }
-      },
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
+      ok: true, value: { items: [item({ version: version('v1') })] },
+    }))
+    mock.remote.messageFeedback.put.mockImplementationOnce(async () => {
+      await gate
+      return ok({
+        ok: false,
+        error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) },
+      })
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
     const listener = vi.fn()
     controller.subscribe(listener)
@@ -407,14 +364,12 @@ describe('MessageFeedbackController', () => {
   it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
     let release = (): void => {}
     const gate = new Promise<void>((resolve) => { release = resolve })
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
-      delete: async () => {
-        await gate
-        return { ok: false, error: { code: 'version-conflict', current: null } }
-      },
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
+    mock.remote.messageFeedback.delete.mockImplementationOnce(async () => {
+      await gate
+      return ok({ ok: false, error: { code: 'version-conflict', current: null } })
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
     const pending = controller.retract(MSG, 'positive')
 
@@ -430,11 +385,11 @@ describe('MessageFeedbackController', () => {
 
   it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
     const existing = item({ version: version('v3'), rating: 'positive' })
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
-      put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
+    mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({
+      ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
 
     expect(await controller.rate(MSG, 'negative', { text: 'far too long' })).toMatchObject({
@@ -446,11 +401,11 @@ describe('MessageFeedbackController', () => {
 
   it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
     const existing = item({ version: version('v4') })
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
-      delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
+    mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({
+      ok: false, error: { code: 'session-not-found', sessionId: SESSION },
+    }))
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
 
     expect(await controller.retract(MSG, 'positive')).toMatchObject({
@@ -466,19 +421,17 @@ describe('MessageFeedbackController', () => {
     const order: string[] = []
     let releasePut = (): void => {}
     const putGate = new Promise<void>((r) => { releasePut = r })
-    const { ctx } = fakeRemote({
-      list: () => {
-        order.push('list')
-        return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } })
-      },
-      put: async () => {
-        order.push('put:start')
-        await putGate
-        order.push('put:end')
-        return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) }
-      },
+    mock.remote.messageFeedback.list.mockImplementation(() => {
+      order.push('list')
+      return Promise.resolve(ok({ ok: true, value: { items: [item({ version: version('v1') })] } }))
+    })
+    mock.remote.messageFeedback.put.mockImplementationOnce(async () => {
+      order.push('put:start')
+      await putGate
+      order.push('put:end')
+      return ok({ ok: true, value: item({ version: version('v9'), rating: 'negative' }) })
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
 
     const rating = controller.rate(MSG, 'negative')
@@ -498,14 +451,12 @@ describe('MessageFeedbackController', () => {
     const gate = new Promise<void>((r) => { release = r })
     let started = (): void => {}
     const listStarted = new Promise<void>((r) => { started = r })
-    const { ctx, calls } = fakeRemote({
-      list: async () => {
-        started()
-        await gate
-        return { ok: true, value: { items: [] } }
-      },
+    mock.remote.messageFeedback.list.mockImplementationOnce(async () => {
+      started()
+      await gate
+      return ok({ ok: true, value: { items: [] } })
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     const pending = controller.rate(MSG, 'positive')
 
     await listStarted
@@ -513,19 +464,17 @@ describe('MessageFeedbackController', () => {
     release()
 
     expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
-    expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
+    expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
   })
 
   it('renders a carrier failure from the Remote envelope', async () => {
     // The generated face folds transport faults into ok:false with a
     // RemoteFailure, so the controller reads them as values, not rejections.
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({
-        ok: false,
-        error: new RemoteError('gateway/internal', 'socket closed', {}),
-      }),
+    mock.remote.messageFeedback.list.mockResolvedValueOnce({
+      ok: false,
+      error: new RemoteError('gateway/internal', 'socket closed', {}),
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.ensure()).toEqual({
       ok: false,
@@ -535,13 +484,11 @@ describe('MessageFeedbackController', () => {
   })
 
   it('renders a carrier failure on a mutation without touching the view', async () => {
-    const { ctx } = fakeRemote({
-      put: () => Promise.resolve({
-        ok: false,
-        error: new RemoteError('gateway/internal', 'socket closed', {}),
-      }),
+    mock.remote.messageFeedback.put.mockResolvedValueOnce({
+      ok: false,
+      error: new RemoteError('gateway/internal', 'socket closed', {}),
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
 
     expect(await controller.rate(MSG, 'positive')).toEqual({
       ok: false,
@@ -551,14 +498,12 @@ describe('MessageFeedbackController', () => {
   })
 
   it('renders a carrier failure on a delete', async () => {
-    const { ctx } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
-      delete: () => Promise.resolve({
-        ok: false,
-        error: new RemoteError('gateway/internal', 'socket closed', {}),
-      }),
+    mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
+    mock.remote.messageFeedback.delete.mockResolvedValueOnce({
+      ok: false,
+      error: new RemoteError('gateway/internal', 'socket closed', {}),
     })
-    const controller = new MessageFeedbackController(ctx, SESSION)
+    const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
     await controller.ensure()
 
     expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })

+ 3 - 0
pnpm-lock.yaml

@@ -3127,6 +3127,9 @@ importers:
       '@deepseek-ai/dsh-message-feedback':
         specifier: workspace:^
         version: link:../../feedback/message-feedback
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session