fix(feedback): address review findings in the controller and controls

Resolve the note-erasure race the review found: a control that rendered
before the first list read held no item and passed note=undefined, so
switching a rating silently dropped the stored note. The controller now
owns note resolution and toggle-vs-retract, deciding from the committed
item inside the serialized mutation, and clearNote expresses deletion.

Also from review:
- serialize the reconnect re-read behind queued mutations (resync), so a
  list reply cannot resurrect a version a newer mutation replaced
- re-check disposal after the seeding read, so an unloaded fiber never
  reaches the wire
- drop Object.freeze on Maps, which does not prevent set/delete
- declare the @deepseek-ai/dsh-client-connection dependency it imports
- surface a failed list load in the controls
- drop the /client value exports that had no consumer
- state the per-turn render scope in the README and subsystem pages
- align the package version with the root
This commit is contained in:
Chinesezjc
2026-08-12 01:12:41 +08:00
parent 63e6ea1b79
commit 47f254a252
19 changed files with 373 additions and 74 deletions

View File

@@ -145,6 +145,21 @@ describe('ui-feedback browser plugin', () => {
})
})
it('routes toggle and clearNote to the controller', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true })
expect(await face.clearNote(MSG)).toEqual({ ok: true })
// The seeded item is positive with no note, so a negative toggle replaces it
// through put, and clearNote has nothing to drop and touches no wire.
const puts = b.calls.filter(call => call.method === 'put').map(call => call.request)
expect(puts).toHaveLength(1)
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'negative' })
})
it('refreshes only Sessions already read when the connection resets', async () => {
const b = await bench()
await b.fiber.await()

View File

@@ -474,4 +474,142 @@ describe('FeedbackController', () => {
})
expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
})
it('preserves a stored note when a rating switch omits one', async () => {
// Regression: a control that rendered before the first list read holds no
// item, so it passes note=undefined; that must not erase the stored note.
const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put.note).toBe('keep me')
expect(put.rating).toBe('negative')
})
it('toggle retracts when the committed rating already matches', async () => {
const stored = item({ version: version('v1'), rating: 'positive' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('toggle decides from the committed item, not a cold view', async () => {
// The click lands before any list read: the cold view knows no item, yet the
// stored rating matches, so the toggle must retract rather than re-put.
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(controller.getSnapshot().status).toBe('cold')
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
})
it('toggle replaces the opposite rating and carries the note forward', async () => {
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put).toMatchObject({ rating: 'negative', note: 'kept', ifVersion: version('v1') })
})
it('clearNote drops the note and keeps the rating', async () => {
const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put.rating).toBe('negative')
expect(put).not.toHaveProperty('note')
})
it('clearNote is a no-op when there is no note to drop', async () => {
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
})
it('resync serializes behind an in-flight mutation', async () => {
// Regression: an unserialized reconnect read could land after a newer put
// and resurrect the version that put had already replaced.
const order: string[] = []
let releasePut = (): void => {}
const putGate = new Promise<void>((r) => { releasePut = r })
const { remote } = 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' }) }
},
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const rating = controller.rate(MSG, 'negative')
const resync = controller.resync()
releasePut()
await Promise.all([rating, resync])
// The reconnect read runs only after the mutation settled.
expect(order.indexOf('list', 1)).toBeGreaterThan(order.indexOf('put:end'))
})
it('refuses a mutation disposed while its seeding read is in flight', async () => {
// Dispose only once the seeding list call has actually started, so the
// mutation is already past the admission check and must be stopped by the
// second guard that runs after ensure() resolves.
let release = (): void => {}
const gate = new Promise<void>((r) => { release = r })
let started = (): void => {}
const listStarted = new Promise<void>((r) => { started = r })
const { remote, calls } = fakeRemote({
list: async () => {
started()
await gate
return { ok: true, value: { items: [] } }
},
})
const controller = new FeedbackController(remote, SESSION)
const pending = controller.rate(MSG, 'positive')
await listStarted
controller.dispose()
release()
expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
})
})

View File

@@ -12,7 +12,9 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
import type {
MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion,
} from '@deepseek-ai/dsh-message-feedback/types'
import { FeedbackActions } from '../src/client/FeedbackActions.tsx'
import type { FeedbackActionResult, FeedbackView } from '../src/client/controller.ts'
import { zh } from '../src/client/locales.ts'
@@ -38,20 +40,29 @@ function mount(options: {
current?: MessageFeedbackItem | undefined
rateResult?: FeedbackActionResult
clearResult?: FeedbackActionResult
status?: FeedbackView['status']
} = {}) {
const view: FeedbackView = {
status: 'ready',
status: options.status ?? 'ready',
items: new Map(options.current === undefined ? [] : [[MSG, options.current]]),
error: null,
}
const ensure = vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true }))
const rate = vi.fn(() => Promise.resolve(options.rateResult ?? { ok: true as const }))
const clear = vi.fn(() => Promise.resolve(options.clearResult ?? { ok: true as const }))
const rate = vi.fn((_id: MessageId, _rating: MessageFeedbackRating, _note?: string) =>
Promise.resolve(options.rateResult ?? { ok: true as const }))
const clear = vi.fn((_id: MessageId) =>
Promise.resolve(options.clearResult ?? { ok: true as const }))
// The controller owns retract-vs-replace, so the double stands in for it:
// matching the shown rating retracts, anything else replaces.
const toggle = vi.fn((id: MessageId, next: MessageFeedbackRating) =>
(options.current?.rating === next ? clear(id) : rate(id, next)))
const clearNote = vi.fn((_id: MessageId) =>
Promise.resolve(options.rateResult ?? { ok: true as const }))
const useFeedback = (<T,>(select: (v: FeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = { messageId: MSG, ensure, rate, clear, useFeedback, t } as unknown as
const props = { messageId: MSG, ensure, rate, toggle, clearNote, clear, useFeedback, t } as unknown as
Parameters<typeof FeedbackActions>[0]
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear }
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear, toggle, clearNote }
}
describe('FeedbackActions', () => {
@@ -91,7 +102,7 @@ describe('FeedbackActions', () => {
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', undefined) })
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
expect(ui.clear).not.toHaveBeenCalled()
})
@@ -100,7 +111,7 @@ describe('FeedbackActions', () => {
fireEvent.click(ui.getByLabelText(zh['action.dislike']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'negative', 'keep me') })
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
})
it('retracts the feedback when the active rating is clicked again', async () => {
@@ -108,8 +119,9 @@ describe('FeedbackActions', () => {
fireEvent.click(ui.getByLabelText(zh['action.likeActive']))
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
// The double routes a matching rating to clear(), mirroring the controller.
await waitFor(() => { expect(ui.clear).toHaveBeenCalledWith(MSG) })
expect(ui.rate).not.toHaveBeenCalled()
})
it('saves a typed note through the rate verb and closes the editor', async () => {
@@ -130,7 +142,7 @@ describe('FeedbackActions', () => {
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' ' } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', undefined) })
await waitFor(() => { expect(ui.clearNote).toHaveBeenCalledWith(MSG) })
})
it('seeds the editor with the recorded note and abandons it on cancel', () => {
@@ -197,6 +209,8 @@ describe('FeedbackActions', () => {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => gate),
clearNote: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
useFeedback,
t,
@@ -214,4 +228,22 @@ describe('FeedbackActions', () => {
window.removeEventListener('error', onError)
expect(errors).toEqual([])
})
it('surfaces a failed list load next to the controls', async () => {
const ui = mount({ status: 'error' })
expect(ui.getByText(zh['error.load'])).toBeTruthy()
})
it('prefers the action failure over the load notice', async () => {
const ui = mount({
status: 'error',
rateResult: { ok: false, error: { code: 'target-not-found', message: 'gone' } },
})
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
expect(ui.queryByText(zh['error.load'])).toBeNull()
})
})